Skip to content

Improve the performance of dissolve transitions - #1355

Merged
ddennedy merged 8 commits into
masterfrom
dissolve-performance
Oct 29, 2026
Merged

ddennedy merged 8 commits into
masterfrom
dissolve-performance

Conversation

@ddennedy

Copy link
Copy Markdown
Member

Supersedes #1343.

This adopts the useful parts of #1343 by @iniw and revises them in response to the review there. Patches 1, 2, and 7 of #1343 are cherry-picked with their original authorship. The follow-up commits address the review comments. Patches 3 (custom yuv420p to yuyv422 conversion in avformat), 4 and 5 (copying probed stream info), and 6 (threaded memcpy in mlt_frame_clone) are not included.

Commits

From #1343 (author @iniw):

  • luma: don't convert opaque frames to rgba for fix_background_alpha. Stops the rgba round trip on every dissolve.
  • luma: dissolve opaque yuv422 frames in slices.
  • avformat: find the error concealment frame in the image cache. Removes a second full deep copy of every decoded frame when the image cache is on.

Follow-ups:

  • framework: add mlt_frame_get/set_resize_alpha. A missing resize_alpha and an explicit 0 now mean different things. The pair is the only way to read, write, or clear the property, so that code that saves and restores it keeps a missing value missing. copy_consumer_image_hints() uses it; before, it wrote 0 onto every frame. New exports are listed under MLT_7.44.0 in mlt.vers.
  • core: request transparent padding with resize_alpha. Replaces the fix_background_alpha frame property that coupled filter_resize to the luma transition. filter_resize creates an opaque mask for a yuv422 image only when resize_alpha is explicitly set to something other than 255. Frames that never set it are padded as before.
  • luma: share the yuv422 frame setup and fix mismatched sizes. The dissolve and the luma-map wipe now use one setup function, and the dissolve uses one slice function. This changes output when the two frames differ in size: each image now uses its own row stride and only the overlap is blended. It contradicts "The output does not change" in the cherry-picked slices commit. Equal sizes are unchanged.
  • avformat: move the error concealment state into conceal.c. One owner for the last good frame (an owned copy once detached, otherwise the image cache key). No behavior change.
  • tests: add test_dissolve. Covers resize_alpha padding and preservation through hints and composite, dissolve and wipe with equal and mismatched sizes, and the concealment state for forward playback, eviction after detach, and backward playback.

Performance

Measured with the test from #1343 unchanged: gen-clips.sh (two 4K60 H.264 clips at about 100 Mbit/s), dissolve.mlt (a Kdenlive-style timeline with a 60-frame luma dissolve with fix_background_alpha, composited with qtblend), and bench.c (one thread pulls yuv422 and s16 audio per frame, like the Kdenlive monitor). Instead of the PR's measure.sh, which rebuilds MLT, master and the branch were run from two run directories that differ only in libmlt, libmltcore, and libmltavformat (plus libmltqt6 for qtblend). Each was run 3 times, alternating.

First play (3 runs) Replay (3 runs) Median
master (7b6ba22d) 4.4, 4.4, 4.5 fps 4.9, 4.9, 5.1 fps 4.4 / 4.9 fps
this branch (0a39760c) 22.5, 23.3, 22.4 fps 44.0, 45.9, 44.9 fps 22.5 / 44.9 fps

That is about 5.1× on first play and 9.2× on replay. #1343 reported 4.8× and 9.0× for its patches 1 and 2 on a faster machine, so the ratios agree. Its further gains came from the patches left out here.

On a real Shotcut project (two 8-bit 4:2:0 HEVC 4K60 GoPro clips with a luma dissolve, rendered offline with an avformat null-muxer consumer, 148 frames), the steady rate went from 8.5 to 34.3 fps (about 4×).

Test system

  • CPU: AMD Ryzen 5 5600G (6 cores, 12 threads), 32 GB RAM
  • OS: Windows 11 Home (10.0.26300)
  • Environment: MSYS2 UCRT64, GCC 16.2.0, FFmpeg 9.0.2
  • Build type: RelWithDebInfo (-O2 -g -DNDEBUG) for both master and the branch. Improve the performance of dissolve transitions #1343's script used Release (-O3).

Not measured

  • The rendered frames of master and the branch were not compared byte for byte. test_dissolve checks pixel values for small synthetic frames.
  • Real-time playback in Shotcut or Kdenlive.

iniw and others added 4 commits October 27, 2026 13:04
Previously, a dissolve between two opaque 4K60 H.264 clips at 100 Mbit/s
ran at 8.3 fps on a 16-core desktop, when played on one thread as the
Kdenlive monitor does. Most of this time went to color conversions that
changed nothing. Kdenlive sets fix_background_alpha on every dissolve,
and with this property, the luma transition requested both frames as
rgba and converted the result back to yuv422. It did this only to make
the padding that filter_resize adds transparent, so for opaque video,
the four conversions had no effect.

The round trip also slowed down the work after the transition, because
it left the result with an alpha mask that was fully opaque. Kdenlive
composites its tracks with a qtblend transition, which saw this mask and
so composited the frame in rgba too.

The transition now requests yuv422 and marks both frames with
fix_background_alpha. The padding stays transparent, because when
filter_resize pads a marked frame that has no alpha mask, it first adds
an opaque one. The alpha of the result also stays correct: when one
frame is translucent, the transition gives the other frame an opaque
mask if it has none, as the round trip did, so that alpha_over can still
compute it.

With this change, the same dissolve runs at 73 fps. Its luma is also
closer to the source, because the rgba round trip clipped colors that
rgb cannot represent. In the first frame of the dissolve, luma is now
identical to the decoded source, and before, the two differed by a PSNR
of 38 dB.

Apart from this, the output does not change. These cases give the same
result as before:

- A 16:9 clip in a 4:3 profile gets transparent padding.
- With alpha_over, a dissolve into a clip with transparency shows the
  track below through it, with the same colors in every frame, also with
  a luma map and with invert.
- With fix_background_alpha off, the output is bit-identical.
Previously, the luma transition split the dissolve of translucent frames
into slices, but it mixed opaque frames line by line on the calling
thread. For 4K frames, this loop took a large part of the time of each
frame. Opaque frames now use slices too, as translucent frames do.

The output does not change, and on a 16-core desktop, a dissolve between
two 4K60 H.264 clips at 100 Mbit/s goes from 73 to 77 fps.
Previously, during forward playback, the producer made a deep copy of
each decoded frame in last_good_frame. It needs the last good frame to
conceal decoding errors: when a frame fails to decode, the producer
shows the last good frame in its place. But the image cache already held
a copy of the same frame, so for a 4K clip, each frame cost a second 16
MB copy that was useful only after an error.

When the image cache is on, the producer now keeps only the position of
the last good frame, and gets the frame from the cache when decoding
fails. The cache can lose that frame in two cases, so the producer
copies the frame before they happen:

- when frames before it go into the cache, which happens during backward
  playback or after a seek back;
- when the cache is reset.

Without the image cache, the producer still copies every frame.

The producer conceals errors with the same frames as before. This was
checked with clips with corrupted parts, played forward, played
backward, after a seek back, after a cache reset, and with a small image
cache or none.

With this change, forward playback is faster. On a 16-core desktop, a
dissolve between two 4K60 H.264 clips at 100 Mbit/s goes from 61 to 69
fps, and in a Kdenlive project with such clips, from 73 to 86 fps.
filter_resize pads with the resize_alpha frame property, and until now a
missing property and an explicit 0 meant the same thing because every
reader and writer used get_int and set_int. To pad an opaque yuv422 image
with transparency without converting it to rgba and back, filter_resize
needs to tell an explicit request from the default.

Add a getter that reports whether resize_alpha is set and a setter that
can clear it, so that code that saves and restores the property keeps a
missing value missing. copy_consumer_image_hints() now uses them: it
previously wrote 0 onto every frame it copied hints to, which would have
made every padded frame look like an explicit request.
@ddennedy ddennedy changed the title Improve the performance of dissolve transitions (supersedes #1343) Improve the performance of dissolve transitions Oct 28, 2026
The luma transition marked both frames with a fix_background_alpha frame
property so that filter_resize would give an opaque yuv422 image a mask
and pad it with transparency. That coupled filter_resize to one
transition through a property that means nothing to it.

filter_resize now creates the mask when resize_alpha is explicitly set to
a value other than 255, and pads with that value, which is what padding an
rgba image did. The luma transition requests it by setting resize_alpha to
0 when fix_background_alpha is on and the frame has no request yet. The
composite transition saves and restores resize_alpha around its b frame
with mlt_frame_get/set_resize_alpha, so that a frame without the property
does not leave with an explicit 0.

Frames that never set resize_alpha are padded exactly as before, without
a mask.
dissolve_yuv422() and luma_composite_yuv422() each requested transparent
padding, fetched both images as yuv422, checked both alpha masks, and
added an opaque mask to one side when the other was translucent. The order
matters: padding must be requested before the images are fetched, and the
opaque mask must be added only when the other side is translucent, or the
result keeps a fully opaque mask and qtblend goes back to rgba. Both now
call prepare_yuv422_pair().

The dissolve also uses one slice function instead of two. An alpha_over
flag picks composite_line_yuv_float() or composite_line_yuv() per line,
and each image advances by its own width.

This changes the output when the two frames differ in size, which
contradicts "The output does not change" in "luma: dissolve opaque yuv422
frames in slices". Before, the opaque dissolve stepped the source by the
width clamped to the destination and ran over the full destination height,
and the alpha-over dissolve used that clamped width as the stride of both
images. A wider source was then read at the wrong rows, and a shorter one
was read past its end. The luma wipe had the same source stride problem.
Now both blend only the overlapping area, each image with its own stride,
and the alpha masks skip the rest of each row. When the sizes are equal,
the output does not change.
The last good frame for error concealment was kept in three fields of the
producer and updated in several places in producer_get_image(): before a
cache put of an earlier frame and before a cache reset, the cached frame
had to be copied out, or a later decoding failure would find nothing to
show. Forgetting one of those copies would silently break concealment.

Move the state and its transitions into a small avformat_conceal type
with record, detach, fetch, and close functions, which are now the only
code that changes it. The picture is either an owned frame, once it has
been copied out of the image cache, or otherwise the image cache key.

The behavior does not change.
Covers filter_resize padding with a missing, an explicit 0, and an
explicit 255 resize_alpha; that copying consumer hints and the composite
transition keep a missing resize_alpha missing; the luma dissolve and wipe
with equal and mismatched frame sizes; and the avformat error concealment
state for forward playback, a cache eviction after detach, and backward
playback.
@iniw

iniw commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Awesome! I wonder what other low hanging fruit performance improvements are just waiting to be discovered :)

@ddennedy
ddennedy merged commit 8a10fee into master Oct 29, 2026
15 checks passed
@ddennedy
ddennedy deleted the dissolve-performance branch October 29, 2026 06:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants