* Timeline: hover texts for the icon-only column headers and layer switches
The Timeline's column header is painted as one click target, so its
icon-only headers (A/V Features, Label, #, the eight switches and the
Modes column's T) had nothing to hover and nothing explained them.
HEADER_TIPS lists each one with a short name and an explanation in our
own words. Hover-only widgets on top of the header show the text, carry a
named ColumnHeader AccessKit node and register automation ids
(timeline.header.av.*, timeline.header.switches.*,
timeline.header.modes.preserveTransparency). The header's clicks and
right-click column menu still work underneath. The layer rows' A/V and
switch toggles and the Preserve Transparency box show the same texts.
The accessibility audit's Timeline baseline drops from 89/42 to 72/25
because the switches now have accessible names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Timeline headers: accessible description, Preserve Transparency text, English menu test
The header's node is named before its hover text is added, so screen readers get the
explanation; Preserve Transparency says the layer follows the alpha below it, partial alpha
included; the hover test sets English before checking the translated Hide This item.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Merge main: keep both sets of timeline tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Timeline tests: close the test before the merged one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
show() went one over the cognitive complexity limit once #3094 landed on
current main, which failed clippy on every later commit.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Cryptomatte: the Layer popup lists the footage's Cryptomatte layers (#473)
The popup was the constant CryptoObject / CryptoMaterial / CryptoAsset. It now lists the
Cryptomatte layers of the layer's OpenEXR footage, and those three when there are none. The
stored value keeps its meaning (0-2 the standard layers, 3 and up the file's other layers in
name order), so older projects render the same.
Refs #473
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Cryptomatte Layer popup: timeline editor, one menu helper, probe and fallback tests (#473)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Cryptomatte Layer popup: give further layers of a standard kind a custom slot
A.CryptoObject keeps slot 0; B.CryptoObject and later ones get 3+ instead of
being dropped, so they can be selected, picked and rendered.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Format; note same-kind layers in the footage docs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Format the merged code with rustfmt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Viewer exposure on the full float range of 32 bpc frames (#2797)
32 bpc viewer frames on the desktop are rendered without the HDR step (skip_hdr_step,
#2916) and kept as half floats in the RAM preview. A display pass on egui-wgpu's device
turns each frame into one RGBA8 texture with mips per viewer, in this order: decode,
exposure in linear light, Compand / Tone Map, encode, display colour, Show Channel,
rounding. Before, exposure was an 8-bit lookup applied after tone mapping, clamping and
the display conversion, so -2 EV could not bring values above 1.0 back.
- engine: viewer::DisplayPass describes the pass as data; its pixel() is the CPU
fallback (no device) and the shader's reference. It replaces exposure_lut /
display_transform. FrameEncoding records a frame's curve and the HDR step it still
needs. 32 bpc snapshots are taken without the HDR step; snapshot_rgba8 applies it.
- ui-egui: FrameImage::CpuF16 (half crate, F16C at run time), byte accounting at 8 bytes
a pixel in the cache and the prefetch estimates; display_gpu.rs + display_gpu.wgsl;
main viewer, snapshot and passive viewers draw through the pass; the 8-bit image other
panels read is made on demand with the HDR step; the Info panel shows float values.
- Half-float frames are not written to the disk cache yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewer display pass: f16 disk cache entries, window-device gating, review fixes
- Disk cache: half-float frame entries (format byte in the entry header, byte planes +
LZ4); 8-bit entries keep format 0 and stay valid. 32 bpc viewer frames are written and
read again, and the timeline's disk bar sees them. A damaged half-float entry is
deleted.
- The display pass runs on the window's egui-wgpu device, with or without the GPU
compositor (still building, failed, GL fallback). Its pipelines are made inside error
scopes; a backend that rejects them gets the CPU pass instead of a blank viewer.
- A new LUT of the same size (another ICC profile, Preserve RGB, another display) runs
the pass again.
- f32 to f16: one pass per L1-sized block, the NaN / infinity fix-up on the bits, no
fill of the buffer (4K: 4.7 ms, as the 8-bit conversion).
- Mips of odd-sized levels include the last column / row; the snapshot's half floats
and texture go with the snapshot; no placeholder texture ids; u64 size checks.
- Tests: LUT profiles and negative values against the shader, Bake reruns, the passive
viewer's colour, f16 disk round trips; exact comparisons allow 1 LSB on software
rasterizers. Docs: the display pass and the f16 disk entries in architecture.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewer f16 disk-cache test: temp dir removed on failure too, wait for the queued disk write
Review points: the test's directory was only removed by its last statement
(now tempfile::TempDir), and the frame is released before its disk write is
queued, so flush() could return early (now waits for the entry).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Viewer: oversized half-float frames use the CPU display pass, so exposure/HDR come before the averaging down
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Viewer: fmt and a debug assert for the half-float upload path
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Disk cache: persist_missing also writes half-float frames
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Passive viewer: oversized half-float frames take the CPU display pass instead of leaving a stale texture
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Masks: set the corner roundness of a Rounded Rectangle mask (#3067)
layer.addMask takes a roundness (corner radius in px) for shape rounded, and the new
mask.setRoundness changes it afterwards on a rectangular mask.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Mask roundness: refuse ellipses, keep feather points and key correspondence
mask.setRoundness accepted any closed path whose vertices lie on its bounding
box, so an ellipse was replaced by a rectangle. It now accepts only a plain
rectangle (four corner vertices, straight sides) or a rounded one (eight
vertices, straight sides alternating with circular corners).
The rebuilt path keeps the old start vertex and direction, and its feather
points, so an animated path's other keys and the feather points still line
up. A change between square and rounded corners changes the vertex count; it
is refused when the path is keyed or has feather points instead of leaving
keys with different counts or dropping the points.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Layer: Quick Set Anchor, nine anchor points with Position compensation (#2967)
layer.quickSetAnchor moves the Anchor Point of 2D layers to a corner, edge midpoint or the
centre of their content and shifts Position and its keys so nothing moves. Layer > Transform >
Quick Set Anchor lists the nine points.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Quick Set Anchor: skip layers with an animated Anchor Point, Scale or Rotation
The anchor is set at the current time and the Position shift is worked out
from the current Anchor Point, Scale and Rotation, so on a layer where any of
them is keyed or has an expression the layer would move at other frames.
Such layers are skipped like 3D and locked ones (an error when none is left).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Quick Set Anchor: skip layers with a Position expression or Auto-Orient along the path
An expression on Position (or the separated X/Y) overrides the shifted value,
and Auto-Orient along the path adds a rotation the shift does not account for;
both would make the layer jump, so they are skipped like 3D layers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Quick Set Anchor: error when no selected layer has content bounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Quick Set Anchor: test separated, keyed Position keeps the layer in place
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Viewer: Alt-drag duplicates the dragged layers (#2968)
Alt/Option-drag of the selected layers in the Composition viewer duplicates them and drags
the copies; the copy and the drag are one undo step. edit.duplicate takes a merge key for it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Alt-drag: duplicate from a 3D gizmo arrow, no double move of a selected child
Alt-dragging a gizmo arrow moved the original; it now duplicates and moves the
copy along the axis. A selected layer whose parent is dragged too follows the
parent, so its own Position no longer gets the drag a second time (with or
without Alt).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Project panel: the Name column fits long names and resizes
The Name column was 40 % of the panel and at most 260 points wide, with
no way to resize it, so long item names (compositions of an imported
After Effects project) ended in "..." even in a wide panel.
It now fits the longest listed name, never narrower than before and at
most the panel's width less the Label column (the optional columns keep
scrolling under it). Dragging the column's edge in the header resizes
it, a double-click on the edge fits the names again. The width is kept
in UiState::project_name_w (None = fit the names); the edge has the
automation id project.nameColumn.edge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Project panel: the Name column leaves room for the optional columns; no name fit for a manual width
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Timeline: one selection box spans the layer list and the time graph (#3056)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Timeline box test: say what it tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Timeline box: a box kept in the time graph selects keys only
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Format the timeline box test and box_targets signature
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Timeline box test: drive the real drag across the outline and the time graph
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Color Stabilizer: sample the reference where the points were at the reference time (#3125)
After Effects 2026 samples the reference frame at the positions the
Black / Mid / White Points had at the reference time. With keyframed
points it therefore corrects even a static picture: our importer sample
(colstab-43-keys, Levels, Black Point (64, 90) -> (151, 99) over 1 s)
renders at 0.5 s as the Levels map out = a + b*v of the ramp with
b 1.2935..1.2965 and a -0.2365..-0.2350, which is the Black Point's
current sample (107.5, 94.5) sent to the reference sample at (64, 90).
EffectCraft sampled both frames at the current points, so the card
stayed unchanged (slope 1). The samples now read the points at the
reference time through the host (params_at), falling back to the
current points without one. CPU and GPU share the sampler
(color_stabilizer_maps).
Sample Size's unit is not pinned by the samples (every size 1..40 leaves
the card unchanged in After Effects; that only bounds the sampled area)
and is left as it is.
Part of #3125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Linear Wipe: After Effects' edge position and feather profile (#3125)
Measured on our importer samples (linwipe-*, 2026-11-05, After Effects
2026, 320 x 180):
- The edge sits at lo + completion * (hi - lo) whatever the feather (the
set-all render's 50 % line is at -5.25, predicted -5.266); 0 % keeps
the layer and 100 % clears it even with Feather 32000. EffectCraft
extended the travel by the feather, 13 px off at Feather 16.
- The feathered edge is the hard matte blurred with the layer's edges
repeated: three box passes of radius 6 per axis fit Feather 16
(sigma 6.35..6.6, taken as Feather / 2.5), including the extra
opacity where the edge meets the layer's left side. EffectCraft drew
a linear ramp Feather px wide.
- Without feather the edge pixel is anti-aliased (143/255; coverage
gives 0.6). EffectCraft drew a hard step.
set-all alpha: before max 195, 4646 px (8.1 %) off by more than 2/255;
after max 15, 529 px (0.9 %), all along the layer's left side (at most
2 elsewhere). Feather 0 renders: max 112 -> 10 on the edge column.
CPU: coverage matte, raster::gaussian_blur (3 box passes, edges
repeated); GPU: the same matte from gen_linear_wipe (new modes), its
gaussian_blur, then a multiply, so both match. Sigma is capped at twice
the layer's longer side; NaN values leave the layer as it is.
Projects saved before keep the old ramp: a hidden legacyFeather
parameter that migrate::legacy_default sets on upgrade (as for Ripple
and Block Dissolve).
Part of #3125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Linear Wipe on the GPU: 100 % clears the layer before the angle is read
A NaN angle left the layer unchanged on the GPU while the CPU cleared it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* AEP import: the report names the After Effects version of the file (#3115)
svap holds the version of the After Effects that wrote a project, head
bytes 4 to 7 the version the file is written for; Save a Copy As 25.x /
24.x of After Effects 2026 keeps svap and writes the older version into
head. AeVersion decodes the packed word (26.2.1x2), AepFile gains
file_version, saved_by_version and head_revision, and ImportReport gains
aeVersion (fileVersion, savedBy, tested; "unknown" when the file does not
say). A version outside 24 to 26 is imported as usual and gets a
limitation line, not a loss.
The 12 corpus families saved as 2026, 25.x and 24.x copies import to the
same project and report; docs/aep-format.md lists what the copies change
in the container. The fixture tests are ignored until the corpus holds
the files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* AEP import: doc comments on the version tests and helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* AEP import docs: tested is a major-version classification from copies saved by After Effects 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* AEP import docs: the importer reports detected losses, not every loss
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* aep tests: compare the imported settings across version copies too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* AEP import: map HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer
The importer reported these five effects as lost. They now become
EffectCraft's effects of the same name with every parameter After Effects
stores (ranges, defaults and popup items from our own After Effects 2026
project, documented in docs/aep-format.md 3.7):
- ADBE HDR ToneMap -> ec.utility.hdrcompression (Amount)
- ADBE Echo -> ec.time.echo (Echo Time, Number Of Echoes, Starting
Intensity, Decay, Echo Operator by item name)
- ADBE Linear Wipe -> ec.transition.linearwipe
- ADBE Offset -> ec.distort.offset (Blend With Original is a stored 0..1
fraction, a percentage here)
- ADBE Deflicker -> ec.color.colorstabilizer (fresh Stabilize default
Levels, points, Sample Size)
A table row can name the range EffectCraft's effect works in
(ParamMap::limit): Echo values or keys beyond 255 echoes or a Decay of 1
are imported as stored and reported.
Tests build each effect into the committed synthetic.aep (fx_inject.rs,
which also checks its pard layout reads back). On the sample project the
effect losses go from 75 to 3 (the out-of-range Echo values).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* AEP import: doc comments on the quick-win test helpers and tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* AEP import: Echo keyframe regression for the range loss
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* #3113: use After Effects' effect match names in scripting
Checked every row of crates/script/src/matchnames.rs against After Effects
26.2 (app.effects, canAddProperty and the layer property trees). Fourteen
effect names were wrong: twelve did not exist (Pixel Motion Blur is
ADBE OFMotionBlur, Shadow/Highlight is ADBE ShadowHighlight, Ellipse is
ADBE ELLIPSE, ...), and Noise and Photo Filter pointed at After Effects'
hidden obsolete versions (now ADBE Noise2 and ADBE PhotoFilterPS). A
light's Casts Shadows is ADBE Casts Shadows.
The old names stay accepted for lookup (addProperty, property(), and
parameters such as "ADBE Noise-0001"), so existing scripts keep working;
.matchName emits only the After Effects name. A names-only fixture of
After Effects' first-party match names backs the new test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Scripting: match names that changed only in case still find effects and parameters
property("ADBE Ellipse") and "ADBE Grow Bounds-0001" found nothing after the names became
ADBE ELLIPSE / ADBE GROW BOUNDS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: stamp footage files for the salt off the UI thread
Importing an After Effects project whose footage includes long image
sequences on a network share froze the desktop app for 15-27 s. The
import itself takes about 0.25 s; the time went into the edit's bump(),
where refresh_disk_salt() stats every footage file (13,466 sequence
frames in the measured project, ~15 s) on the UI thread whenever a disk
cache is attached. File > Open and footage reload hit the same path.
With Session::disk_salt_background (set by the desktop app) the salt is
taken on a worker thread and picked up by poll_disk_salt() from the UI
tick. Until it lands the layer cache runs memory-only, so keys under the
old salt (other files, or another project's items) are never read. A
newer footage change supersedes a salt still being taken. Headless
sessions keep taking it synchronously.
A settings change that keeps the disk cache folder no longer re-stamps
every footage file either.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Disk cache: opening a project drops the footage salt being taken
A salt job still running for the old project was kept when the new project named the
same footage, so the salt stamped before the files changed stayed in use.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Home screen: leave it when a menu command opens a composition
File > Import > After Effects Project... from the Home screen imported the
project (the toast said so), but the Home screen stayed on top, so it
looked as if the import had done nothing. Only the Home screen's own
buttons left it.
run_engine now leaves the Home screen when a command succeeds and changes
the active composition to one that is open, the rule scheduled script
tasks already follow. Frontend events run afterwards, so Help > In-App
Tutorials and New Project from Template still show it. A command that
opens no composition (or fails) keeps it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Home screen: also leave it for a project that replaces one with the same composition id, a picked file and the New Composition dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* interchange: probe EDL media the reader flags offline (#2836)
An EDL line without a SOURCE FILE comment only names its clip. The reader
guesses the path beside the list and flags the media offline, and the
importer never probed offline media, so the file next to the EDL stayed
missing. Offline media is probed like the rest; a path that is not there
still becomes a placeholder.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* interchange: probe guessed paths of EDL media only (#2836)
An OTIO missing reference also gets a path built from the clip name and
is flagged offline, so a same-named file beside the document would have
replaced the intended clip silently. Only EDL imports probe offline media.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* interchange: confine guessed EDL paths to the list's folder before probing (review point)
CodeRabbit: an EDL clip without SOURCE FILE gets a path from its clip name; a
name like ../x.mov or an absolute one could link a file outside the EDL folder,
and rebase_to_file_timecode probed it too. Such guessed paths are now not
probed and stay placeholders; SOURCE FILE paths are kept as before.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* EXR export: write the output space's chromaticities attribute (#3037)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* EXR chromaticities: tests for unmanaged and PQ ProEXR, every space once
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* EXR export: note why the comp background is added before decoding (review point)
CodeRabbit asked to convert Comp.background to the output space before
compositing. Every output path (Image::to_rgba8_over) adds the background to
the output-encoded frame as it is, so the EXR now matches the other outputs;
converting would make the EXR colour differ from the PNG of the same comp.
Comment only.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Format the merged code with rustfmt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
After Effects 2026 accepts 0 to 200 for Brush Size of both effects (a
script setting 201 is refused with "out of range 0 to 200"); the range
here ended at 50. The slider keeps its 0 to 50 travel; larger values are
typed.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Content-Aware Fill: bit-pack holes, spill frames to disk, stream outputs (#2570 phases 2-4)
* Content-Aware Fill phases 2-4: bit-pack holes, spill frames, stream outputs (clippy clean)
* Address CodeRabbit review: spill safety, streaming correctness, perf, memory guard (#2570)
* Address the remaining CodeRabbit review findings on Content-Aware Fill (#2570)
A non-streaming fill returned empty frames, progress could jump backwards between
the sink and the fill, the memory guard ignored the membrane frames still resident
for the flow pass (and wasm's in-RAM copies), and the GPU fill stages ran outside
an error scope, so an OOM retired the shared context instead of falling back to
the CPU for that frame.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Content-Aware Fill: sample spilled frames without decoding them, check spill errors (#2570)
Tracing sampled spilled frames by decoding whole frames under the shared cache lock, so
parallel traces across neighbouring frames evicted each other and mostly paid a full-frame
read while every other thread waited. Sample only the 2x2 bilinear support with positioned
reads on an open handle instead, and report a failed spill read in the no-hole branch
rather than streaming black frames as a successful fill.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Content-Aware Fill: spill membrane/propagated frames, unquantized input, surface scratch errors (#2570)
Flow and propagation now write their intermediate frames to scratch spills beside
the output instead of holding the whole range as float RGBA, and the input spill
uses 32-bit floats whatever the project depth so the fill sees the same values as
the old in-memory path. Spill reads/writes and scratch creation report through a
shared error slot that run_plan surfaces instead of masking disk failures as a
cancellation. Sampling uses positioned reads; the guard models the packed masks
plus per-thread scratch.
* Content-Aware Fill: report a membrane-spill write failure instead of cancelling (#2570)
* Content-Aware Fill: include the flow fields in the memory guard (#2570)
* Content-Aware Fill: scale the memory guard with workers and check scratch space (#2570)
The guard's scratch term was a fixed 8 frames, which under-counted a wide
machine with Lighting Correction by several times. It now models the sequential
synthesis stage plus a few frames per worker (from available parallelism) and
takes Lighting Correction into account.
Native fills write three f32 copies of the range to scratch beside the output,
so a range that cannot fit on that volume is now refused up front with the
free space in the message, before any frame is rendered.
Tests cover the worker/lighting budget, the scratch formula, a shared error slot
between the input and secondary spills, an uncreatable spill folder, and the
engine surfacing a spill failure as an error instead of a cancellation.
* Content-Aware Fill: resolve a relative output folder for the scratch check (#2570)
A bare relative output name has an empty parent, so the walk-up found no
existing ancestor and skipped the free-space check. Resolve it against the
process directory first, and cover that in the test.
* Do not reformat the GPU fill tests in the spill branch (#2570)
The file was already not rustfmt-clean upstream, so the formatter only added
noise to a file this PR no longer needs to touch: phase 1's call-site changes
are already on the base branch.
* Content-Aware Fill: measure peak RSS and correct the per-worker scratch model (#2570)
The ignored measure_peak_rss_of_a_fill test fills a spilled 640x360 range and
reports VmHWM. On this 18-worker machine 90 frames peak at 340-470 MB across
runs (35 MB with RAYON_NUM_THREADS=1) and 600 frames at 582 MB, so the scratch
slope is ~7 frames a worker, not the 2 the first guard revision assumed.
Scaling the summary down showed a clean linear slope at 8 and 4 workers, and
Lighting Correction stayed below the flow stage's peak.
Budget 8 frames a worker (9 with Lighting Correction), just above the measured
top of the range, and adjust the guard test to the real numbers. At 600 frames
the measured peak is 582 MB against ~11.1 GB for the old five-copies model, a
19x reduction.
* Contributors: credit Madhav Agarwal in the About window
Adds the self-chosen real and display name for @gmrmad1. The generated
contributors.json picks it up once the PR is merged.
* Address CodeRabbit: size the guard from the rayon pool; migrate GPU tests (#2570)
fill_workers used available_parallelism, but the parallel fill stages run on
the rayon pool, which RAYON_NUM_THREADS can size larger. Use
rayon::current_num_threads so the scratch budget follows the threads that
actually run; wasm keeps rayon's single-thread fallback.
The GPU inpaint tests still used the pre-spill FillInput API. The earlier
'do not reformat' revert dropped their API migration along with the
formatting, leaving cargo test -p effectcraft-gpu uncompilable. Restore the
Frames::Memory / HoleMask / sink migration and verify the 15 inpaint tests.
* Fix gate: flatten the wasm prop lookup and keep the budget wrapper test-only (#2570)
* Address CodeRabbit review: spill error check, global-pool preflight, docs (#2570)
* Include RAM-backed spill in the preflight and guard streaming reads (#2570)
* Resolve relative spill paths, prefer stacked mounts, guard the Edge Blend sink (#2570)
* Make the fill spill anonymous and test the streaming claims (#2570)
The spill was a named file in the output folder that only Drop removed, so
an out-of-memory kill or a force-quit left a multi-gigabyte
effectcraft-fill-* file in the user's project folder, and a failed set_len
after keep() leaked it too. The spill is now an anonymous temporary file
(tempfile_in), which the system reclaims when its last handle closes; every
read and write is positioned on that one handle (pwrite/seek_write join the
existing pread/seek_read), and the folder is kept only so the propagated
frames spill beside the input.
Also:
- Edge Blend checks for a failed read before handing a frame to the sink,
so a black fallback frame never streams.
- The propagation workers stop as soon as the spill records an error.
- HoleMask::get/set use get()/get_mut(), so a mask whose public words are
shorter than len cannot panic.
New tests cover the PR's headline claims: a spilled range fills
bit-identically to the same frames in RAM (returned and streamed), the sink
sees frames strictly in order, a truncated spill fails with a recorded
error and streams nothing, a failure mid-stream stops before the next frame,
propagation stops after a failure, the spill has no name in its folder, and
HoleMask round-trips at word boundaries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: clean up a stopped fill and count two scratch copies (#2570)
- A fill that fails or is cancelled after frames started streaming deletes
the PNGs it wrote, and the output folder when the fill made it and it is
left empty (OutputFiles, dropped after the spill so Windows can remove
the folder).
- After a successful fill, run_plan checks that every frame has a file, and
apply_plan/add_fill_layer return an error on an empty range or file list
(a job worker's reply) instead of indexing [0].
- scratch_need charges two f32 copies (32 B/px): flow_pairs drops the
membrane spill before the propagated one is created, so the third copy
never coexists.
- The spill-creation error carries a stable "spill: cannot make the scratch
file" marker; the panel test asserted on the OS wording, which on Windows
is "Cannot create a file when that file already exists".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware Fill: a stopped fill leaves files it didn't create (#2570)
A fill into a given outputDir that already holds an earlier sequence
overwrites its frames as it goes (as before). The cleanup of a failed or
stopped fill deleted every path it wrote, including those earlier frames, so
a layer still using that sequence lost them. It now deletes only the files
the fill created (CodeRabbit on #3079).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* #293: use conformant ProRes alpha run termination
Pin the direct ProRes encoder to FilmCraft e4be1577 while preserving all other codec pins. Add a full MOV export regression for final alpha runs across both 4444 profiles, alpha modes and blur settings.
* #293: document ProRes alpha export regression
* export: repin FilmCraft to the merged ProRes alpha fix and move the round-trip regression into roundtrip.rs
All FilmCraft deps pinned to d5ead4ba (squash merge of filmcraft #2991). H.264 pinned movie bytes move 104341 -> 104342: only the encoder version string in the SEI (0.8.0 -> 0.12.0) and five size fields change.
* Grow the buffer to the pinned quad so Corner Pin draws outside the layer rectangle (#1938)
* Corner Pin: grow through pad_sides, skip adjustment layers, clamp to the pixel budget (#1938)
grow_sides returns the four sides for Buf::pad_sides on both paths, replacing grow_to/regrow and the inline GPU copy. Growth is skipped on adjustment layers, cut proportionally to the shared pixel budget (and 16384 per side) instead of giving up, and covered by tests for a far pin, an adjustment layer and a degenerate quad.
* Corner Pin: give the grow_sides test its own doc comment (#1938)
* effects: Corner Pin keeps a finite buffer for non-finite pins and grows without popping as a pin moves far away
* effects: rustfmt the Corner Pin non-finite pin test
---------
Co-authored-by: skylar <hi@skylar.cc>
The reference test now checks the bound the PR states in the code space of the renders
(the sRGB curve x 6553.5) instead of a linear 2e-3, which allowed about 28 codes at the dark end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A record in another tdsb state is reported as damaged data again, as before the
default-state change. The synthetic test checks Levels' recorded popup directly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New After Effects 2026 renders of the same EXR at Amount 25, 50 and 75
(our own 1110-extra sample, 16-bit PNGs) confirm the transfer curve that
was fitted at 84, 90 and 100. The worst channel error over all pixels is
4.0, 5.5 and 4.3 of 65535, against 997, 2415 and 3867 for the old soft
knee. The code is unchanged.
The measured-reference test now covers all six Amounts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The effect used a soft knee on the brightest channel and left everything
below the knee unchanged. After Effects compresses the whole range. Fitting
our own 16-bit renders of an HDR ramp and coloured patches (Amount 0, 84,
90, 100) gives:
Y = max(0.30 R + 0.59 G + 0.11 B, 0)
comp = min(c / (1 + Y), 1) per channel
out = ((1 - t) c^0.4 + t comp^0.4)^(1/0.4) t = Amount / 100
A grey value x maps to x / (1 + x) at Amount 100. The worst error against AE
drops from 985 to about 2-4 of 65535 at Amounts 100, 90 and 84. Amount 0
stays identical.
CPU and GPU change together. Tests: a reference table of measured values,
GPU parity at Amount 90, and over-range input that exercises the clip at 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After Effects records some effect parameters as value records in the
default state (tdsb 3) that still hold the value it uses. A survey of 839
corpus and sample files finds 25 such effect records, all in Lumetri
Color (its hidden controls) and Cryptomatte (Selection Mode on); every one
differs from its pard default and reads back in After Effects as recorded.
No effect record in the default state holds a value that does not count.
The general reader now takes them: EffectParam::recorded_value (the same
name, signature and body as in the Lumetri import) reads a record whatever
its state, value() and is_default() use it, and the importer's mapped
parameters read it, so such a record imports like a written one instead of
being reported as an unreadable value with the default used.
stored_value keeps to written records.
Layer, camera, light, shape and text properties in the default state all
hold After Effects' defaults or values with no effect on that layer
(the point light cone, a polygon's inner radius, a camera's opacity), so
their readers are unchanged. docs/aep-format.md records both findings.
Closes#3126
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With Matte Only on, Cryptomatte wrote the matte into all four channels:
a premultiplied white only as opaque as the matte. After Effects'
Cryptomatte plug-in shows Matte Only as an opaque grey picture, the
matte in RGB with alpha 1 (its Matte Only render of 2026-11-05 is opaque
black where nothing is selected). The CPU effect and the GPU shader now
both give (m, m, m, 1).
Projects saved before keep their render: a hidden Matte Only In Alpha
switch (off for new instances, presets and AEP imports) is turned on by
migrate::legacy_default when an older instance is upgraded on open, and
then keeps the matte in every channel on CPU and GPU.
Tests: the CPU path for a partly covered pixel, an unselected one and
outside the aux area, in both forms; the upgrade of a saved instance;
the GPU parity test over every display with Matte Only off, on and on
with the legacy switch; the host pick tests read whole pixels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* expr: an expression cannot rebind a prelude name for the next one (#2133)
One runtime serves every expression on a thread and user code runs as a script
at global scope, so `linear = function () {}` rebound the name for every later
expression, and for every later project in the session. #1387 closed the `__`
globals, the model constructors and the prelude's own prototypes; the rest of
the global surface is here: the prelude's own 104 helpers, the built-ins that an
expression and the prelude both call, and the prototypes those hang off.
Freezing a prototype costs an expression the right to shadow an inherited name
on its own object: once the inherited data property is non-writable, `o.x = …`
is dropped in sloppy mode rather than creating an own property, which is the
override mistake. Every name that was shadowable before is therefore replaced
by an accessor whose setter writes an own property on the receiver, emulating
OrdinarySetWithOwnDescriptor so the substitution does not show through.
The repair covers every own writable, configurable data property of every
prototype the block reaches, not a list of commonly shadowed names: a list
leaves `a.map = …` throwing and `a.push = …` and `a.join = …` silently running
the native method, which is worse, because the expression keeps working and
quietly computes something else.
`name` and `message` on an error's prototype are the one exception, and they
are frozen rather than repaired. The host renders a thrown error by reading
both off the object with no context to call a getter with, and nowhere safe to
call one: the time budget is checked between polls of the evaluation and
formatting runs after that returns, so a getter of the expression's own would
run outside it, bounded only by the loop and recursion limits at some hundreds
of millions of iterations. `e.name = 'MyError'` is dropped in silence as the
price of keeping user code out of the failure path.
The setup code reads every intrinsic it needs before the first prototype is
rewritten, and indexes arrays rather than calling their methods. Repairing
`Array.prototype` turns `concat` and `forEach` into accessors, and an earlier
attempt at this used both while walking the remaining globals, which left
every prototype after `Array.prototype` unfrozen and the prelude failing to
load at all.
Measured on macOS arm64, release, min of 3 x N, against this base: an
inherited-name read costs ~68 ns where it is now an accessor, which is +18%
on a loop doing a hundred of them and +2% to +7% on ordinary expressions.
Extending the repair past `Object.prototype` is what the array-method read
pays for; nothing else moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* expr: the prelude's lock answers delete and defineProperty too (#2133)
Tests for the two routes to a global binding the existing ones did not cover
against a prelude function, asked for in review. `writable: false` answers an
assignment and a `var`; it is `configurable: false` that answers these, and
they fail differently: a `delete` of a non-configurable property evaluates to
false in sloppy mode, while a `defineProperty` over one throws, so that is the
one route whose refusal the user sees.
Also records which mode decides that, because it was asked and the answer is
not the obvious one: an expression cannot reach strict mode at all.
`Runtime::script` wraps the text in `try{…}catch(__e){throw __e}`, so a
`"use strict"` the user writes is an expression statement inside a block
rather than a directive prologue and has no effect. A refused write is
therefore always the silent kind, never a TypeError.
No change to the prelude; these pass as it stands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* expr: freeze Math and JSON plainly instead of repairing them (#2176)
Math and JSON are namespaces, not prototypes: nothing inherits sin or
parse, so the accessor repair keeps no shadowing there and only puts a
getter call on every Math.* read of a per-frame expression. Freeze them
directly and lock the two bindings so Math = {} cannot replace them for
the next expression either. Prototypes keep the repair.
---------
Co-authored-by: Anubhav-Rai <adobearakap@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
A press on the ruler right beside the marker bin, released a few points
away on the bin, is still a click, and it counted as one on the bin; two
of those added a marker. A click now counts as the bin's only when both
its press and its release are on the bin, in a frame when the bin is
enabled and has the pointer (not a popup above it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bin counted a double-click from egui's flag for the whole frame and
the number of releases over it, so three clicks in one frame, on the bin,
beside it and on it again, still added a marker. It now walks the
frame's pointer events itself, by egui's rules for a click and a
double-click, and adds one only for two clicks in a row that are both on
the bin. It also counts while the bin is dragged or its drag has just
stopped, not only while the pointer is over it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui takes any click within a few points of the last one for the first
click of a double-click, so a click on the ruler right beside the bin and
then one on the bin added a marker. The bin now remembers when it was
last clicked and adds one only when the first click was on it too (or
both came in one frame over it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sensing clicks on the marker bin as well as drags held the drag back
until the pointer had moved, so a drag out that was released in that
same frame (a fast drag, or a slow frame) added nothing. The bin senses
drags only again, so its drag starts the moment it is pressed, as
before #1826, and the double-click is read from the pointer over it.
The double-click test now also gives the second press and its release
frames of their own, with the bin dragged in between.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* plugins: install effect plug-in bundles (wasm + ScriptUI panels) in one go
* plugins: address review - keep listing errors, refuse replacing an installed plug-in
* plugins: report unreadable bundle entries when their metadata cannot be read
* plugins: surface partial install errors in the menu bar, cover the install flow with tests
* plugins: surface partial install errors for async dialog answers too
* plugins: show result errors as compact status text
* plugins: harden installs - confined automation, no panel overwrite, size cap, cleanup errors
* plugins: bound install reads from one handle, detect unreadable panels, clarify install docs
* plugins: drop an unneeded mut in the bounded read
* plugins: saturate the bounded read limit
* plugins: install into the session's Plug-ins folder, refuse hidden single installs and case-variant panels
- Install .wasm files into Session::plugins_dir() (the folder the Plug-in Library
from #2524 uses), not a path rebuilt from the settings folder; the host tests
now point both at their temp folder so they never touch the real settings.
- A single install of a hidden file (".x.wasm") was skipped and reported as a
success that installed nothing: it is now an error.
- Installed names must be bare file names (and not Windows device names on
Windows), as file.installScript requires, so every installed panel is listed
and can be uninstalled.
- A panel whose name differs from an installed one only in case replaced it on
Windows and macOS: compare ignoring case and check the file on disk too.
- The loaded plug-in's source is the installed copy, not the file it came from.
- Document the `bundle` menu parameter, so checked execution accepts it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* plugins: drop needless to_vec in the bundle install test (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* plugins: keep the loader test's byte vectors, drop to_vec in the install test (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: STAR Web Studio <hello.starwebstudio@gmail.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Plug-ins: API 2 (PluginHost, padding, layer_span)
Split out of the OpenFX work so it can be reviewed on its own. Additive; the WebAssembly ABI
stays at 1.
- point3 / layer / text / hidden parameters, integer sliders, parameter groups.
- EffectPlugin::padding (transparent pixels around the buffer, at most 4096 per side).
- EffectPlugin::render_v2 with a PluginHost: the input at other times, other layers, parameters
at other times, frame rate, per-instance key, the layer's source range.
- EffectEnv::layer_span from the renderer; MAX_PLUGINS 1024.
- plugin_error: a plug-in effect's last render error, for the UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS
* Plug-ins: external effect helpers (crates/helper-host)
Native plug-in formats run in separate helper programs, never in EffectCraft's process
(docs/adr/0001-external-effect-helpers.md). New safe-Rust crate effectcraft-helper-host (L3,
native only, unsafe forbidden): it finds helper programs, starts each as a child process and
registers its effects through plug-in API 2, so the UI and command surface need nothing new.
- Protocol v1 (docs/external-helpers.md): JSON lines over stdin / stdout (helper replies are
prefixed so plug-in chatter is ignored); pixels go through a frame exchange file (/dev/shm on
Linux), never through the pipe. Render-time callbacks for the input at other times, other layers
and parameters at other times, with nested renders through the same helper.
- A helper that exits, hangs past its timeout or breaks the protocol is stopped. The render fails
with a message, the frame is left as it was, and the helper restarts for the next render. An
effect that stops its helper 3 times is turned off (helper_blocklist.txt).
- Opt-in: nothing starts helpers unless the user asks (Effect > Scan for Effect Helpers), or opts
in to starting them at launch with EFFECTCRAFT_HELPERS=1, an environment variable, so writing
to the settings folder alone can't run code. effect.helpers.scan / load are refused for
confined agent sessions (confine.rs), like file.executeFile.
- Discovery: helpers/ next to the app (Contents/Helpers on macOS), helpers/ in the settings
folder, EFFECTCRAFT_HELPER_PATH.
- Commands effect.helpers.list / scan / load; Effect menu entries (translated); the last render
error of an effect shows as a warning sign after its name in Effect Controls.
- serve(): the helper side for Rust helpers. tests/it/main.rs runs the whole protocol (render,
padding, callbacks, nesting, threads, crash, restart, block list, hang) against a fake helper,
with nothing to install.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS
* Effect helpers: address review (start failures, frame file mode, nextest listing)
- A helper that can't start (not installed any more, restarting too often, no frame exchange
file) is reported but no longer charged to the effect being rendered: it is Failure::Refused,
not Failure::Broken, so it never counts toward turning that effect off. New check in
tests/it: once the helper is out of restarts, renders of an effect fail without blocking it
(fails without the fix: the effect was turned off after 3 such renders).
- The frame exchange file is created owner-only (0o600) on Unix: it holds the user's frames and
/dev/shm is shared. Asserted in the frames unit test.
- tests/it (harness = false) speaks the libtest listing protocol cargo-nextest uses:
`--list --format terse` prints `helper_protocol: test` (nothing with `--ignored`), and
`helper_protocol --exact` runs the checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxacwTpUwqyfpcLgKW8yjS
* Effect helpers: bound writes to a helper, drain stderr, keep callbacks in step
- Writes to a helper go through a writer thread with the request timeout: a
helper that stops reading its stdin (hung with a large callback answer or
render request pending) no longer blocks the render thread forever while
holding the helper.
- stderr is drained as bytes (lossy, in 64 KiB pieces) to the end: output that
isn't UTF-8 stopped the reader, closing the pipe under the helper (EPIPE /
SIGPIPE on its next write); lines were unbounded.
- Callback images that can't be written are answered with `null` instead of
failing the render while the helper waits (which left the protocol out of
step); a frame that can't be written before the request is sent is not
charged to the effect.
- Mid-render calls use the render's own process: no respawn or heartbeat ping
while the helper is waiting for an answer.
- Waiting for a busy helper is bounded (load timeout) instead of forever;
status() no longer blocks on a running render.
- Reap the child when start-up fails after spawning; cap the block list read.
- Scripts start helpers only with Allow Scripts to Write Files and Access
Network on (they run native code), as for File.execute().
- helper-host declares the workspace's no-panic crate lints; the desktop
main() keeps its doc comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Effect helpers: rustfmt the fake provider, build the test session in its initializer (clippy)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Plug-in Library: offer WebAssembly API 1 releases only; a helper already stopped mid-render isn't charged again
- WasmPlugin loads only modules at the WebAssembly ABI version, which stays 1 while
PLUGIN_API_VERSION is 2: share it as effects::plugin::WASM_API_VERSION and have the
library's compatibility check use it, so it never installs an API 2 release that
can't load.
- with_running: a process that is already gone was stopped by what broke it (a nested
render, charged there), so this render fails without charging its own effect.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* frames: plug-in effects count as reading other frames (plug-in API 2)
With main's narrowed frame invalidation (#2795), an edit inside a precomp under an
effect that reads its layer at other times must reach the layer's whole span.
Plug-in API 2 gives every plug-in effect the input at other times, so any effect
outside ec. counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
* frames: keep cached frames an edit can't reach
Nudging, trimming or splitting a layer re-rendered the whole comp. The RAM
preview now carries every frame whose stack of active layers is unchanged
to the new comp identity; only the segments an edit touches re-render.
* disk cache: never drop frame writes; save frames kept after edits
Frames get their own write lane (bounded by bytes, written before layer
buffers), so a burst of layer writes no longer drops frames and leaves gaps
in the blue bar after reopening. Frames an edit kept in RAM are written to
disk under the comp's new content key when idle.
* frames: music and hidden layers keep the caches
Layers that can't change pixels (audio-only footage, AV layers with Video
off, when nothing reads them and there are no expressions) no longer count
for the RAM preview's dirty segments or the disk cache's comp and footage
keys. Adding, trimming or moving music keeps both caches.
RENDER_VERSION bumps to effectcraft-render-3 for the new key formula.
* ui-egui: background caching benchmark
cache_bench fills a comp's RAM preview the way idle caching does and
prints frames/s with decoder, footage-cache and disk-cache counters.
* frames: soloed music and hidden layers aren't silent
Solo hides every other layer, so soloing a music track or a layer with
Video off changes the picture. silent_layers no longer drops soloed
layers, so the RAM preview and the disk cache see Solo toggles.
* disk cache: audio files a layer reads stay in the layer salt
Audio-only footage left the disk layer salt so importing music keeps the
keys, but Audio Spectrum and Audio Waveform draw from an audio layer.
Footage another layer reads (or any, when a read layer is a precomp)
counts again, so overwriting that file doesn't reuse stale buffers.
* disk cache: retry frame writes the queue refused
put_frame reports whether the frame is on disk or queued. persist_missing
forgets the key of a refused write, so a full frame lane no longer leaves
RAM frames that are never written.
* ui-egui: cache_bench measures idle caching; checks --comp and --slots
Frames go through request_background with one slot per frame thread, as
Cache Frames When Idle does. An unknown --comp is an error (the active
comp is used only without --comp), --slots is at least 1, and the disk
report flushes queued writes instead of sleeping.
* disk cache: RENDER_VERSION effectcraft-render-6
This branch changes the cache-key formula (silent layers, Solo, read
audio in the footage salt), and upstream #2759 already took render-5
for the clip-clock change, so entries from either must not be reused.
* frames: rewrite RAM frames the disk cache evicted
persist_missing kept every disk key it ever queued, so a frame the disk
cache evicted later was never written back. The disk cache now tracks
frame writes still queued or in progress (frame_pending), and
persist_missing only remembers keys while it converts them.
* frames: idle passes continue while RAM frames wait for disk
persist_missing reports whether there may be more to write (a content key
being computed, frames being converted). Cache Frames When Idle then
schedules another pass, so a refused write is retried without waiting for
a UI event, and the passes stop once every frame is on disk or queued.
* frames: rewrite an evicted frame once, not on every idle pass
A disk cache smaller than the RAM preview, or a frame too large for it,
made idle passes write, evict and rewrite the same frames every 500 ms.
Each disk key is now written at most twice under one disk limit (the
write and one rewrite after an eviction); a new limit resets the count.
The persist test waits for the conversion to forget its keys before
checking that no work is left.
* frames: the work area doesn't change pixels
Setting the work area (B, N) cleared every cached frame, in RAM and on
disk: it was compared with the comp's pixel settings and hashed into the
disk key. It is a preview and render range only, like the Resolution, so
it no longer counts.
* frames: audio edits keep frames unless something draws the audio
Changing a clip's Audio Levels, its audio effects or its Audio switch
cleared the clip's cached frames, and the comp's disk cache, although
nothing drew that audio. The audio now counts only for the layers whose
audio is heard (Project::heard_layers): those a layer picks in an effect
(an Audio Waveform or Spectrum, even on itself), the layers of a picked
precomp, and every layer while an expression (which can read Audio
Levels) exists. Music in a precomp that a waveform in the outer comp
picks is no longer treated as silent either.
* frames: an expression only reaches the frames of its own layer
One expression anywhere in the project made every edit clear the
comp's whole cache, and so did editing a layer another layer reads
(its parent, matte, or a layer it picks). Expressions can read
anything, but they only draw in their own layer: an edit now re-renders
the frames where it changed something, plus those of the layers with an
expression and of the layers that read a reached layer. With
expressions, an edit elsewhere (another comp, an item's name, a marker,
music) reaches only the expression layers' frames.
* frames: an edit inside a precomp re-renders only where the precomp shows it
Editing anything inside a precomp cleared every cached frame of the
comps that nest it, even a music level in it. Project::dirty_spans now
follows precomps: it takes the spans their comp changed in, shifts and
scales them by the precomp layer's start and stretch (its whole span
under a time remap or Responsive Design timing), and keeps the outer
comp's other frames. Layers that read a changed precomp (a matte, a
waveform) re-render where they are active.
* disk cache: keep the frames an edit can't reach
The disk cache keys frames on a hash of the whole comp, so every edit
lost the frames that were only on disk (the blue bar), even a rename;
only those still in RAM were written again under the new key. When an
edit keeps RAM frames, it now also queues a disk carry: on the frame
pool, each kept frame of every disk series in use for that comp is hard
linked (or copied) to its new key with DiskCache::link_frame, oldest
edit first. The old entries stay, so undo finds them too.
* frames: a keyframe edit re-renders only around the keys it changed
Changing one keyframe (its value, interpolation or ease, in the
Timeline or the Graph Editor) re-rendered the layer's whole length, so
a key on a full-length layer cleared the whole cache. A layer that
differs only in keyframes of its Transform properties now re-renders
from two keys before the first changed key to two after the last
(auto-Bezier and continuous tangents reach the neighbouring segments),
to its in or out point past the first or last key. Static values,
expressions, roving keys, time remap and time effects still reach the
whole layer; effect keyframes do too for now (simulations and time
effects read other frames).
* frames: music volume keeps frames while no expression reads audio
Any expression in the project (a wiggle on another layer, even a
disabled one) made every layer's audio count as read, so changing a
music layer's Audio Levels re-rendered every expression layer and
changed the comp's disk key. Audio now counts for expressions only when
one may read it (Heard::audio_exprs): its text names audio or walks
properties by index. An audio-only change nothing reads no longer counts
as a change elsewhere in dirty_spans either.
* frames: a keyframe edit with few keys or beside a self-contained expression keeps the other frames
Changing a Scale key of a layer with two or three keys re-rendered the
layer's whole length: with fewer than two keys before (or after) the
changed one, the span fell back to the in (or out) point instead of the
first (or last) key, past which the value holds. And any layer with an
expression re-rendered on every edit, so a wiggle on a full-length layer
cleared the whole cache. An expression layer is now reached by edits
elsewhere only when its text may read outside its layer (it names a
comp, layer, footage, the project or its source); its parent and the
layers it picks are still read through referenced_layers.
* viewer: the caching chip stays up between uncached frames, with a spinner
The "Caching frames…" chip showed only while playback waited for a
frame, so during caching it flickered on and off with every frame that
arrived. It now stays up 0.6 s after the last wait (and hides at once
when playback stops). It is also smaller and quieter: a spinner, then
"Caching frames" without the ellipsis in dimmed smaller text, on a
fainter chip sized to its text.
* preview: the Preview panel's cache bar counts the preview's Resolution
With a preview Resolution set (Half, Third…), the frames a preview
plays are cached at that scale, but the Preview panel's bar counted the
series the viewer shows. When playback stopped, the viewer went back to
the comp's Resolution and the bar dropped from full to the frames cached
there. It now counts the preview's own series (Auto still follows the
viewer). The viewer and the Timeline's cache bar are unchanged.
* Contributors: add nazar-gavrylyk's name
* frames: an expression reading its own layer's markers or audio re-renders that layer
With expressions in the project, a layer's markers count when comparing it, and its audio counts
when an expression may read audio, so a marker or volume edit re-renders the edited layer over
its own span. Comp markers still count only through layers whose expressions name thisComp.
* frames: a keyframe edit on a layer with an expression re-renders the layer
An expression on the keyed layer can read the keyed property at other times (Rotation reading
Position with valueAtTime), so the frames past the changed keys may change too.
* cache_bench: read decoder counters through effectcraft-media
Only crates/media, crates/export and crates/interchange may depend on the FilmCraft crates.
* cache_bench: clips end where the next one starts
* frames: write-back uses the worker's disk keys while a Roto Brush model is active
persist_missing keyed disk frames on FrameKey::opts, which also carries the Roto Brush model
stamp; the worker and disk_frames key on the render opts' hash alone. With a model active every
RAM frame looked missing, was written again under keys nobody reads, and recorded a second disk
series.
* frames: an edit two precomps down reaches the outer comp
A precomp layer whose comp Arc didn't change was skipped, but the comps nested in it may have
(an edit in Inner leaves Mid's Arc as it was), so Outer kept every frame. Every nested comp is
walked now, each once per edit.
* cache_bench: stop when the disk cache can't be cleared
* frames: one disk carry job at a time
carry_disk runs on every timeline paint and idle pass, and spawned a pool job whenever carries
were queued; during a drag the extra jobs waited on the carry lock and held frame-pool threads
from the viewer. A scheduled flag keeps one job, which drains the queue in order until it's
empty.
* frames: a panicking disk carry is skipped, not left holding the job
The carry job clears carry_scheduled only on its normal exit; a panic in a carry would have left
it set for the session, so no later carry ran. Each carry is caught like the content key's
hashing, so the job always reaches its exit.
* frames: an expression reading its own in point or index re-renders its layer
A trim or rename kept the layer's frames outside the cut when the layer
drew the same apart from its span, but an expression on it can read its
own inPoint, outPoint or name (a fade from inPoint), so every frame of
the layer changes. Such a layer is no longer treated as only respanned.
An expression reading `index` reads the layers above it too: a layer
added or removed there shifts it, so `index` now counts as reading
outside the layer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* frames: an edit of a comp reaches the expressions of the precomps it nests
An expression in a precomp can read the comp that nests it
(`comp("Main").layer("Controls")`), but the walk into nested comps only
counted changes outside the edited top comp. Editing a hidden Controls
layer there (or any layer, when the precomp is time-shifted or reads it
at other times) kept every precomp frame in RAM and linked them all on
disk under the new key. Nested comps now see an edit of the comp above
them as an edit elsewhere.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* frames: an edit in a precomp under a time effect reaches its whole layer
A precomp layer running Echo, Posterize Time, Time Displacement or
another effect that reads the layer at other times shows a nested change
far from where it maps, but only the mapped span (a nested frame either
side) re-rendered. Such a layer now gives its whole span, like a time
remap. `reads_other_frames` lists those effects (every `ec.time.` and
`ec.sim.` effect and the others that call `self_at`); keyframe spans use
it too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* frames: a keyframe edit beside muted keys re-renders the whole layer
Muted keys are skipped when a property is evaluated, so the keys that
play around an edited key can be any distance away, but the span counted
two stored keys either side. A segment from an earlier playing key kept
its stale frames. Muted keys now give the layer's whole span, like
roving keys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* frames: one echo in the precomp time-effect test (three saturate alike), rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The bin at the right end of the time ruler only answered a drag, so a
marker had to be dragged out to where the current time was. As in After
Effects, a double-click on it now adds a composition marker at the current
time, one undo step. A single click still adds nothing, and dragging a
marker out of the bin works as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>