An RGB PNG or a JPEG decodes to RGBA8 with alpha 255 everywhere, but only a frame with no alpha
plane counted as opaque, so each frame of a still was converted to a full-size region and mixed
over a zero-filled canvas: two 33 MB float images at 1080p instead of one, for every frame an
export renders at once. A region that is the whole picture with every alpha exactly 1 is now an
opaque full layer, which the compositor takes as the canvas. The output is the same bits.
With no colour tags, FilmCraft decodes with BT.709 transfer and
primaries and the matrix by frame size, and probe and media.colorInfo
showed those values with nothing saying they were defaults, so an
untagged file looked the same as one tagged BT.709.
VideoStreamInfo gets color_assumed (optional, left out when unknown):
which of the matrix, transfer and primaries no tag in the file gave.
MP4/MOV and Matroska take it from the same sources as the colour, APV
from its colour description, and AVI, MXF and Y4M mark all three.
probe prints it as video.color_assumed and media.colorInfo as assumed,
next to the unchanged metadata.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* #3948: time interpolation applies when a clip's frame rate differs from the sequence's
A 24 fps clip in a 29.97 sequence at 100 % rendered the same with Optical
Flow or Frame Blending as with Frame Sampling, repeating one frame in five:
interpolation_blend gave up whenever the speed was 100 %. It now gives up
at 100 % only when the media's frame rate matches the sequence's, so it
takes the sequence rate too. Clips at the sequence's own rate still never
fetch a second frame.
* #3948: a still image gets no in-between frame
A still reports 29.97 fps and returns the same picture at every time, so
in a sequence at another rate it now passed the frame rate check: Optical
Flow ran on two copies of one frame, and the GPU plan fell back to the CPU
for nothing. media_interpolation skips still sources, and the item layer,
media_image and the GPU plan all ask it.
* #3948: an in-between frame never comes from past the clip's source out
A reversed clip starts 1 tick before its source out, so on a source frame
boundary interpolation_blend paired its last frame with the frame at the
source out, at a weight near 1: the first frame showed media the trim had
cut. A forward clip could end the same way (23.976 on 29.97, 5 frames
ending on a source frame blended in 20 % of the next one). Speed changes
had this already; rate conforms at 100 % now take the same path. When the
later frame starts at or past the source out, the clip shows its last
frame alone. The later frame always starts after the source in, so the
other end needs nothing.
* lumetri.presetThumbnails: write through the host and keep the grid to the presets
The PNG was written with std::fs::write instead of the session's Services,
the path every other export takes (#379), so on hosts without a filesystem
it trapped and elsewhere it ignored the host's file handling. The grid was
also as wide as `columns` asked (up to 32) whatever the number of presets:
there are ten, and 32 columns of 1920 px cells made a 61 572 x 1 084 image,
over a gigabyte of f32 pixels and a 267 MB PNG for one query. Columns now
stop at the number of presets shown. Found by fuzzing commands with hostile
parameters (it took over 10 s per call in a debug build).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* render: the preset grid copies thumbnails with checked slices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Luma Key only made pixels below Cutoff transparent, so a light
background could not be keyed. Add a Key Out Brights option (default
off) that inverts the matte; projects without the param render as before.
Tested: luma_key_can_key_out_brights (default, missing param, inverted).
Fixes#2648
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* render, gpu: Bokeh Blur gathers through a kernel of whole-pixel weights, 5x faster on the GPU
Each bilinear tap's four weights fall on the same offsets around every pixel (a pixel's centre is
a whole number and a half), so vfx::bokeh_kernel sums them per offset once per frame: about 330
reads at amount 10 instead of 1764. The CPU gathers through it, and the GPU reads it from a small
texture in the gather pass's aux slot, in the same order. 1080x1920, M4 Max: amount 10 hexagon
15 -> 3.2 ms on the GPU, 781 -> 114 ms on the CPU; amount 12 disc 58 -> 6.7 ms on the GPU.
New test bokeh_kernel_matches_the_bilinear_taps keeps the old gather as the reference.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* render: Bokeh kernel offsets stay within 2^20 pixels; the CPU gather adds them saturating
A huge amount on an image too small to halve kept its whole radius, so an offset could reach
i32::MAX and wrap when added to a pixel position (on the GPU, and on 32-bit CPU targets). An
offset past any image's width reads the edge pixel either way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* effects: give 4-Color Gradient a position for each colour (#3682)
The four colour points were hard-coded a quarter in from the corners, so
the gradient could not be arranged (the reporter had no position for the
fourth colour). Point 1-4 are now parameters; a point left at its NaN
default keeps the old position, and a project saved before they existed
has no such params, so both render exactly as before.
Test: a project without the points and one with the old positions set
explicitly render the same as the default; moving Point 4 shifts the
colours.
Fixes#3682
* effects: resolve the 4-Color Gradient points to the quarter positions (#3682)
Responsive Design - Time remaps only a graphic clip's layer keyframes;
the clip's own effects and masks are read at plain clip time. Applying
the remap to them would move keyframes in existing projects, so keep the
behaviour, state it in docs/graphics.md and pin it with a test
(layer keyframe remapped, clip Opacity keyframe not).
Fixes#3361
New Three-Way Color Corrector (Obsolete folder, like Color Balance): a
hue/balance wheel and a level shift for shadows, midtones and
highlights, tonal range thresholds with softness, and Saturation. All
parameters go through the generic param path (keyframable, undoable).
CPU (FxOp::ThreeWay) and GPU (OP_THREE_WAY) share the arithmetic; the
wheel is converted to an RGB offset on the CPU so the shader stays
simple. Defaults are the identity.
Tested: three_way_color_corrector_works_per_tonal_range (identity, each
wheel affects only its range, saturation 0 is grey) and two GPU/CPU
parity cases in fx_tests.
Fixes#3465
* feat(text): strikethrough for graphic text
Text layers get a Strikethrough toggle next to Underline: per layer
(`strikethrough` parameter, Properties ▸ Text ▸ Style "S") and per
character (`graphics.setCharStyle` style `strikethrough`, stored in the
layer's style runs).
The stroke uses the font's OS/2 strikeout position and size; a font that
leaves them out gets a stroke centred on half the x-height. Strokes are
added to the layout's decoration rectangles next to underlines, so every
renderer and the path export draw them without further changes. Vertical
text gets a stroke down the middle of each cell.
generated with Claude Code
* contributors: add alexanderalber
generated with Claude Code
* graphics properties: draw the letter-button marks in a helper
Keeps the Properties scroll closure under the cognitive-complexity limit with the new
Strikethrough button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
On the CPU compositor every monitor frame was converted from Y'CbCr to a
premultiplied linear f32 image (16 bytes per pixel) and from that to sRGB
bytes, even when the frame is a single opaque clip shown as it is. The canvas
now keeps such a frame unconverted until something needs its pixels (another
layer, a transition, an adjustment layer, a display transform, captions).
render_sequence_rgba8, which the monitor's CPU path uses, converts a frame
that stays that picture alone directly to the bytes the float path gives,
through per-code-pair tables for R and B and one table for G, at full size and
decimated. Tests compare it bit for bit with the float path for every matrix,
range, chroma layout and decimation, and through the compositor's shortcut
scenes.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
over_black_rgba8 and over_black_with_alpha_rgba8 computed each value with a
table fetch, a clamp, a saturating float-to-usize cast and, for the alpha, a
libm round call, which kept the loop scalar. The table indices of a batch of
64 pixels are now computed first in a loop that vectorises and looked up
after, and the alpha is rounded without the library call. The bytes are the
same; a test checks every table and rounding boundary and the floats around
them.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- The H.264 encoder's RGBA -> 4:2:0 conversion runs on the render
threads (VideoEncoder::prepare_ahead), which are idle while a batch is
encoded; encode() picks the planes up by picture index and converts a
picture itself when there are none (then on the encoder's own pool,
not the busy global one).
- rgba_to_yuv420_8 uses per-code tables of the same products, rounds
without roundf and works row by row so the divisions vectorise; the
output is pinned against the float formula for all 2^24 colours.
- Full-size opaque 8-bit Y'CbCr -> linear conversion tabulates R' and
B' per code pair with the expressions of the per-pixel loop.
- over_black_rgba8 fetches the sRGB table once per picture.
bench_export (1080p H.264 + AAC to H.264 + AAC, Ryzen 16 threads, 7
interleaved rounds) with the encoder change: median 7.86 s -> 6.14 s
(-21.9 %), min 7.78 s -> 6.02 s (-22.7 %). Same file (MD5) as
before, and the same for a ProRes export of the clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* security(media): bound generator frames, imported audio dimensions, and render images
* fix(media): unwrap render result in demo_frames example
* fix(render, codecs): guard zero-sized images, check post-scale sequence pixels, and bound combined MXF channels
* fix(codecs): annotate channels as u32 in MXF sound track combination
* fix(render): preserve exact output_size scaling calculation for renderable_sequence validation
* fix(media,render): reject unsupported audio dimensions and invalid render scale
Sample rates above MAX_AUDIO_SAMPLE_RATE were clamped without resampling, so a
768 kHz stream was reported as 384 kHz and played at half speed. Reject them
instead, and drop the stream at the four codec call sites rather than
advertising dimensions the samples do not match.
output_size now treats a non-finite or non-positive scale as 1.0 instead of
casting it to zero and collapsing the frame to 1x1.
* fix(media): keep Matroska audio tracks and their metadata together; guard empty images
A track whose advertised sample rate or channel count is refused is dropped before the track list is built, so atracks[k] and audio_streams[k] stay the same track. over_black_with_alpha_rgba8 and Image::transformed return early for an empty image instead of chunking by zero.
The monitors flattened every frame over black before showing it, so the
transparent areas of a title, graphic or clip with an alpha channel
showed as black, and Display Mode > Alpha showed white everywhere. The
frame cache now keeps each pixel's alpha, with the same color bytes as
before, and texture_for still uploads frames opaque.
Transparency Grid, in View and in each monitor's wrench menu, paints a
white and #cccccc checkerboard of 8 pt squares behind the picture and
uploads the frame with its alpha, so the grid shows through. It is off
by default, and each monitor keeps its own setting. A magnified picture
paints only the squares in view, and in Comparison View the reference
frame gets the grid too. The GPU compositor's output and the render
previews are opaque, so while the grid is on the Program monitor and
the reference frame render the sequence live on the CPU
(Target::SequenceLive). Display Mode > Alpha now shows the frame's
alpha, and the Program monitor renders the sequence live for it too,
also in ranges that have a render preview.
The command is view.transparencyGrid; like the other view toggles it
takes {"enabled": bool} and {"monitor": "program"|"source"}, and the
setting is transparency_grid in ui.inspect and ui.set.
* Tests: one integration-test binary per crate
Each crate with several tests/*.rs files keeps them as modules of tests/it/,
so cargo links one test executable per crate instead of one per file. Files
that change process-wide state or time a workload stay binaries of their own.
* Profiles: line tables for workspace crates, no debug info for dependencies
* Windows ARM64: nightly and on release pushes instead of every push to main
* xtask ci: clippy and tests in the dev profile
The release profile's thin LTO and single codegen unit made linking the test
binaries take most of the gate. The codec and audio crates stay at opt-level 3
in the dev profile, so their tests run at release speed.
* xtask ci: nextest, doctests, no examples, one wasm check
Tests run with cargo nextest when it is installed (cargo test otherwise) and
build with --lib --bins --tests, so examples aren't linked; doctests run
separately. The wasm check keeps going past the first failing crate. VP8
fixture generation locks across processes, since nextest runs each test in
its own.
* VP9 synthetic fixtures: write aside and rename
A test in another process could read a fixture ffmpeg was still writing.
* Fuzz tests: a third of each sweep by default, all of it with FILMCRAFT_FUZZ_ITERS
Every seed, mutation kind and thread count still runs. Header bytes each get
one overwrite value and two bit flips in rotation, so every value and bit
index still occurs across the region. FILMCRAFT_FUZZ_ITERS=N runs the whole
sweep plus N random rounds per seed, as before.
* Platform fallback tests: one failure point per hybrid path
The failure-anywhere test injected a failure at four points around every
restart point; the hybrid's paths differ only in the first GOP, around the
second and last restart points, at the end and never. The replay test needs
the first two GOPs.
* Export tests: shorter ranges with the same checks
The bitrate test measures 0.5–1.5 s, where each preset reaches its own
target (on 0–3 s the simple demo left two presets at the same rate). The
every-preset test exports three frames per preset.
* Audio tests: effects in parallel, a table-driven FFT for the Opus quality metric
The extreme-settings test keeps both settings and the 40 s tail for every
effect, one thread each. The Opus quality metric's FFT builds its twiddles
once and allocates nothing per transform (same algorithm and summation order).
* CI: dev-profile gate with nextest
xtask ci no longer builds with --release, so the release-profile overrides
go; nextest is installed for the test step.
* CI: apt packages through the cached install-apt-packages action
* Review fixes: fuzz tests with a panic hook in their own binaries, loud fixture lock
The fuzz tests of av1, h264, hevc, mpeg2v, vp8 and vp9 count panics through
a process-wide hook, so a panic in another test of the same process would
fail them; they are binaries of their own again. The VP8 fixture lock fails
the test instead of skipping it silently, and the full-sweep doc comments say
how many truncation points the full run samples.
* Fuzz tests: rotate the default subset per run (FILMCRAFT_FUZZ_SEED replays one)
* platform tests: decode-switch tests in their own binaries, one NVENC lock
* security(render): bound expanded frame plans
* render: count plan layers as they are pushed instead of re-summing every step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Luma vs Sat and Sat vs Sat went through the periodic hue-curve LUT, so a
point in the highlights reached around into the shadows: with points
(0.5, 0.5) and (0.9, 0) black got 0.04 instead of the neutral 0.5, i.e.
a highlight desaturation also desaturated the darkest pixels by about 93%.
They now use a plain (non-wrapping) curve, flat beyond the outermost points,
in the render and in the curve editor.
The hue curves were already periodic, but the LUT slice was off by a third
of a sample: entry i held hue (3i+1)/(3n-1) instead of i/(n-1), leaving a
0.23 degree gap at red. With a steep curve through red that was a step of
0.02 between hue 359.99 and 0. Sampling 3n-2 entries puts the slice on the
exact grid, so the first and last entries are both red and equal.
generated with Claude Code
Bokeh Blur had no GPU op, so a clip carrying it left the GPU compositor's
layer path and every frame with it composited on the CPU: up to 625
bilinear taps per pixel, about 3.3 s per 1080p frame at a 12 px amount.
- render: Bokeh Blur is an FxOp (parameters evaluated in FxOp::eval, the
CPU reference in FxOp::apply, the id in GPU_EFFECTS). The CPU result is
unchanged; a non-finite amount is now a no-op instead of NaN.
- gpu: four passes on the top-left part of the working textures: each
halving (Image::downsample2), the highlight boost, the iris gather
(Image::sample_bilinear_clamped within the halved image) and the scale
back up (Image::transformed's bilinear).
1080p, RTX 4070 Laptop vs 16 CPU threads: amount 6 947 ms -> ~9 ms,
12 3250 ms -> ~39 ms, 20 617 ms -> ~6 ms, 60 (default) 62 ms -> <1 ms.
Tests: six parity cases (no-op, every halving depth, shapes, rotation,
highlight boost) match the CPU to < 1e-6; hostile rotation / highlight stay
on the CPU, huge and NaN amounts run bounded on the GPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Use Effect Shutter Angle on and an angle above 0.5 the CPU blurs the
motion over seven samples (vfx::distort::transform_fx), but the GPU chain
accepted the effect by id and FxOp::eval placed one picture, so the
Program monitor and GPU exports silently lost the blur. eval now declines
under the same condition and the layer renders on the CPU.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Lens Blur transition blurred both pictures with a disc of golden-angle
samples and computed every sample's offset (a square root, a sine and a cosine)
again for every pixel, though the offsets depend only on the radius and the
sample count. They are now worked out once per frame (`disc_offsets`) and the
pixels come out bit for bit the same. A 1080p picture at the default radius
blurs in 50 ms instead of 95 ms (1.9x), and a Lens Blur frame blurs two.
The effect smoothed the whole analysed path and scanned it for the worst border
again for every frame it rendered, so the cost of a frame grew with the length
of the clip: 0.8 ms for a 30 s clip, 6.8 ms for 2 minutes and 14.8 ms for 10
minutes, on every frame of playback and export.
The corrections, and the worst border per zoom limit, are now kept in a small
bounded cache keyed by the analysed path and the Smoothness, Result and Preserve
Scale settings. The numbers are the ones `corrections` and `cover_scale` gave
before.
The analysis decoded every frame of the clip, built the tracking pyramid of each
one, and kept all of them until the steps between frames were known. Memory grew
with the length of the clip: a 10-minute clip at 30 fps is 18,000 frames, and
at the Detailed analysis size (960 px wide, about 2.8 MB per frame) that is tens
of gigabytes, which ends the process.
The frames are now decoded, prepared and tracked 64 at a time and dropped once
the steps they take part in are known; only the last frame of a block stays for
the next one. The steps, the analysis size and the handling of unreadable or
resized frames are the same as before, whatever the block size, and cancelling
still stops the analysis between blocks.