Commit Graph
4331 Commits
Author SHA1 Message Date
Luis dbcae60e19 Release: refresh contributor credits for each release (#3189) (#3190)
About > Contributors still showed an old snapshot, because nothing
regenerated contributors/contributors.json before a release. Opt in to
the shared refresh from craft-common-actions: a read-only credits job
regenerates the snapshot for the release commit, and the shared release
workflow installs it before every source build. The snapshot is not
committed back.
2026-11-08 03:06:20 +01:00
Valentin KleinandClaude Opus 5.5 84382487bb Disk cache: entries keep the files they read, keys from memory (#3184) (#3191)
* Disk cache: record the source files each frame reads

Part of #3184 (step 1): renders now report which footage files they read,
each with its identity (size and modification time) as read. Nothing uses
the list yet: images, cache keys and cache behaviour are unchanged.

- render: `reads` module with `FileStamp`, `ReadFile`, `ReadFiles` (sorted,
  deduplicated, bounded; marked incomplete when it can't vouch for every
  file) and the thread-safe `ReadLog`. `Renderer::reads` collects a frame's
  files; `FootageSource::recording` lets a source note what it reads.
  `file_stamp` uses the same identity.
- Layer cache entries keep the files their buffer was made of and report
  them on every hit; derived entries (styles, blend space, flattened) keep
  their base's. Buffers from the disk cache or the browser store don't know
  their files yet and mark the list incomplete.
- Media pool: every file read notes its identity, taken from the file it
  opens. Decoded frames, opened movies, GIFs, models and EXR channels keep
  it with the cache entry, so a frame served from memory reports its file
  without touching the disk.
- Viewer frame worker collects each frame's list and carries it to where
  the disk entry is written (step 2 stores it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Automation: confined footage passes on the files it reads

Part of #3184: ConfinedFootage forwards FootageSource::recording to the
source it wraps, so renders of an automation session list their files
instead of coming back incomplete. The read tests also render through the
viewer's CancellableFootage wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: frame entries keep their source files, comp key from memory

Part of #3184 (step 2). Fixes #3175: opening a comp no longer stamps every
footage file of the project before its first frame can show.

- The comp's content key (`disk_cache::comp_content_key`) is taken from the
  project in memory alone: no footage or proxy file is stamped, no
  `VirtualFileId` is asked. `comp_content_key_with` is gone; `frame_key`
  hashes a new entry version, so entries of the earlier format are never
  looked up again (LRU evicts them).
- Frame entries (formats 2 and 3) store the files the frame read
  (`EntryFiles`: size and modification time as read; size and content hash
  through `VirtualFileId` for files from memory; missing files), then the
  pixels. Formats 0 and 1 read as misses and are deleted.
- Check on hit: `get_frame`/`get_frame_f16` look only at the listed files
  (one metadata call each) on the frame worker. A changed, back or unreadable
  file, or a damaged entry, is a miss: the entry is deleted and the frame
  rendered and stored again.
- A frame whose file list is incomplete is shown but not stored. The RAM
  preview keeps each frame's files, so `persist_missing` writes only frames
  with a complete list.
- Remote frame worker: `FrameServer` records each frame's reads
  (`take_files`); the web worker stores them with the entry and the page
  checks them through the file table before using it. The browser's media
  epoch existed only for the old key and is removed. Export does not use
  frame entries.
- The cache indicator still only asks whether an entry exists.

Tests: entry format round trip and damage, the check per file, and in the
viewer: a changed file re-renders exactly its frames (and a reopen reads the
rest from disk); a frame checks only its own files in a project with a large
unused sequence and the key looks at no file; missing, unreadable files and
damaged entries never serve a stale frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: layer entries keep their source files, no footage salt

Part of #3184 (step 3 of 4).

Layer buffers on disk (and in the browser's layer store) now carry the
files they were made of, like frame entries since step 2, and are checked
against exactly those files when used. The project-wide footage salt is
gone: nothing stamps every footage file any more.

- Layer entry format 4: the buffer's EntryFiles, then the pixels. A hit
  checks only the listed files (metadata, or the VirtualFileId hook for
  files from memory; Missing must still be missing); a change, a damaged
  entry or the old format 0 is a miss and the entry is deleted, so the
  re-rendered buffer replaces it. A hit reports its file list, so frames
  made of buffers from disk are complete and get stored again.
- Buffers whose files can't all be named are not written.
- Layer disk keys: layer_disk_key = hash of the layer entry version, the
  renderer version and the layer cache key (which names the footage items
  with their paths and settings, from memory).
- PrefetchStore checks on use; an entry made of changed files is dropped
  and forgotten, so the new buffer is queued over it. FrameServer passes
  its virtual-file hook to its layer cache.
- Removed: footage_salt(_with), file_stamp, footage_stamp,
  set_disk_salt, set_store_salt, Session::refresh_disk_salt,
  poll_disk_salt, footage_fingerprint, disk_salt, disk_salt_background,
  disk_salt_job, DiskSaltJob, the UI's per-frame poll, the salt in
  FrameServer and in the availability worker (with its cache_enabled
  plumbing). The disk cache is attached as soon as it is configured.
- Docs: architecture.md, web.md.

Tests: layer entries check their files (size, time, missing/back,
folder, damaged, old format, incomplete); virtual files; prefetch store;
media tests with a real pool across restarts (only the changed file is
read, projects with the same item ids never share an entry, missing,
unreadable and damaged never serve stale buffers); engine tests that the
cache is attached at once and edits do no file work (#1926, #3106).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: no-salt worker test, sequence paths in the frame-blend key

Part of #3184 (step 3 of 4).

- A frame-blended footage frame's layer-cache key now hashes the
  sequence's file list: with no project-wide salt, two projects whose
  items share an id and a first frame must not share its disk entry.
- Test: a frame worker syncing, patching and rendering a project with an
  unused 5000-frame sequence asks its file hook only about the file its
  buffer reads (the salt asked about every file at every sync).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: the comp key covers what its expressions can read

With an expression anywhere in the project, every comp's disk key hashed
every item of the project, so any edit (another comp, an unused footage
item) threw away every comp's frames on disk. The key now covers what the
comp can draw plus what the expressions among it can reach
(Project::pixel_reach):

- without expressions: the items the comp draws, without silent layers,
  plus the footage its precomp layers swap in (Media Replacement);
- with expressions: every layer of the comps they reach, their markers,
  the comps and footage they name with string literals (comp("Name"),
  footage("Name")) together with which items have those names now, and
  the footage named like a reached source (its data is read by name);
- the whole project when an expression computes a name, uses the object
  model's constructors or internals, dynamic code, computed keys or
  writes, or text the scanner can't read for sure (regex literals,
  template strings, escapes): expression_reach errs towards reaching more.

Markers count only where an expression can read them (#1083). Memory
only, no file is looked at.

Tests: scanner and reach unit tests in the project crate; engine tests
for the key (own-comp expressions keep the key through edits elsewhere,
comp("Other") follows Other and not a third comp, a second comp taking
the name counts, a computed name counts every item, markers read from
another comp); a ui-egui test that the disk frames outside an expression
layer are carried when the comp it names changes.

Part of #3184. Part of #1492.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: Reload Footage drops the entries of its files, docs

File > Reload Footage now deletes the frame and layer entries whose file
lists name the reloaded item's files (DiskCache::drop_files), so a file
replaced by one with the same size and modification time, which the
check on hit can't tell apart, is read again. The index knows keys, not
files: the writer thread reads the files section at the start of each
frame and layer entry (not the pixels) after the writes queued before
it, and deletes the entries that list one of the files. Until it is
done, an entry made of one of them is a miss and frames aren't linked to
new keys. The entries are gone for good, so they don't come back after a
restart. Automatic reloads don't drop anything: they follow a changed
stamp, which the check on hit catches. The browser needs nothing (its
file identities are content hashes).

Removed: disk_cache::seal, which steps 2 and 3 left without callers (the
tests use seal_format). Updated the module, seal_format and forget_media
docs, and docs/architecture.md / docs/web.md describe the final design:
what is cached, keys from memory, file lists checked on hit, nothing
scans the project, incomplete lists stay in memory, Reload Footage drops
entries, edits keep the frames they can't change.

Tests: render unit tests (same size and time passes the check but not
the drop; other files' entries stay; an entry queued before the drop is
dropped; a layer entry too; written again afterwards it is used; the
drop survives a restart; a pending drop is a miss), and an engine test
through file.reloadFootage.

Closes #3184. Part of #1492.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: file stamps keep the whole modification time

A file's modification time was nanoseconds since 1970 as u64: a time before 1970
read as "no time" (two such files of one size looked alike) and times after 2554
were truncated. It is now i128 nanoseconds, negative before the epoch; None only
means the file system has no time. The content-key hash writes a presence tag and
the full value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Automation: a refused footage read leaves the render's file list incomplete

Footage outside the read roots shows nothing and was noted as no file at all, so a
picture made without it had a complete, empty list and could be kept on disk for
a later session with other roots. A refused read during `recording` now marks the
list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: a buffer kept by another worker comes with its own files

LayerCache::insert returns the files of the buffer it keeps, and Renderer::keep
notes them in the render's log and returns them, so a render that uses another
worker's buffer lists that buffer's files (a file changed between the two reads
then has two identities and the list is incomplete).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: sampleImage and text fonts note the files they read

EvalCtx carries the render's read log. A sampleImage render records its footage
reads, keeps them with its per-thread cached buffer and notes them in the
evaluating render's log on every use. Text notes the font files it is drawn with,
with the identity their bytes were read with; a family that isn't installed, a
fallback or missing glyph, or a font from memory leaves the list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: frame entries store the whole modification time

A frame entry's file list stores a file's modification time as i128 nanoseconds
since 1970 (negative before), as the file stamps now keep it; the frame entry
format version goes to frame-entry-files-2 so entries in the old layout are not
read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: layer entries store the whole modification time

Layer entries share the frame entries' file list layout, which now stores the
full i128 modification time: the layer entry format version goes to
layer-entry-files-2. The module doc names the font files text is drawn with
among an entry's source files, and what keeps a list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layer cache: buffers that read other layers are keyed by the switches passed down

A layer whose effect reads another layer (Gradient Wipe, Set Matte, Blend…) gets
that layer drawn by its own renderer. Through a precomp layer, Switches Affect
Nested Comps, the switches inherited from the precomp layers above and motion
blur on change those pixels, but the key held only the layers' own switches: a
buffer made with the setting on was reused (from memory or the disk cache) with
it off. Such layers now derive their key with what the renderer passes down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layer cache: masked buffers are keyed by the mask motion blur gate

A mask with Mask Motion Blur On blurs even when the layer's own switch is off,
as long as the render's gate is open (Render Settings motion blur, and with
Switches Affect Nested Comps the precomp layers above). The key held the
layer's own motion blur state only, so a buffer blurred with the gate open was
reused with it closed (and the other way round), also from the disk cache.
Masked layer, input and footprint keys now tell a closed gate apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: cached buffers list what their expressions and substituted characters read

- Buffers made under `Renderer::recorded` (layer content, a Time effect's input, an adjustment
  layer's footprint) evaluate their masks, shapes and effects with the context that notes into
  the buffer's own read log, so a file an expression reads (`sampleImage`, also at shutter times
  mask motion blur adds and the cache key doesn't see) is in the buffer's files.
- A styled layer served from the cache lists the files of the cached body and passes too, so
  the buffers made of it (flattened, blending space) don't get a complete list when one of them
  came with an incomplete one.
- A character put in by Character Offset or Character Value without a relayout notes the font
  it is drawn with; a fallback face (or none) leaves the list incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render: text lists the font file of a run that draws no glyph; tests clean their temp dirs

An empty first paragraph draws no glyph, but in a box its face sets the
first baseline that places every later line. The read list took faces only
from glyphs, so it could be complete without that file. Note each style
run's resolved face as well.

The font-reads and confine tests use tempfile::tempdir(): a fresh
directory per run, removed on panic too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: Reload Footage closes the gaps around its drop (#3184)

- A reload while the disk cache is off is kept on the session
  (pending_disk_drops) and applied when the cache is switched on, before
  anything is read from it.
- Each render takes the cache's drop generation before it reads (frame
  worker; layer cache at the miss). An entry listing a file dropped after
  that is refused under the queue's lock, so a render that was under way
  during the reload can't store pixels from before it. Such a frame is
  shown with an incomplete file list, so persist_missing skips it too.
  The per-file record is bounded (past it, everything older is refused).
- An entry read while its drop was pending is a miss even when the
  writer ends the sweep between the read and the check (a stale hit
  found under load): the pending drops and the generation are taken
  before the entry is read.
- Test: a reopened project serves its frame from disk with no source file
  opened or decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Disk cache: RAM frames keep the drop generation their render started at

Reload Footage empties the RAM preview, but an idle pass of
persist_missing may already hold a frame from before the reload. It
took the cache's drop generation when it ran, after the drop, so the old
pixels could be written back and, with the file's size and time
unchanged, served again after a restart.

Each RAM frame now keeps the disk cache its render started with and
that cache's drop generation then. persist_missing writes a frame only
to that cache, with that generation, and skips it once one of its files
was dropped since; a frame rendered without that cache is not written
from there.

Tests use tempfile directories (the layer drop test, the frame_files
folders under target/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Doc comments for the functions this PR touches

* Disk cache: cap the free-space reserve at 10 GB, skip encodes the limit refuses

The reserve the cache leaves free on its disk was max(2 GB, 5% of the
disk) (#2551). On a 4 TB disk with 190 GB free that is 200 GB, so a
cache set to 20 GB had a limit of 0: every write was refused, the cache
never held anything, and only a log warning said so.

The reserve is now clamp(5% of the disk, 2 GB, 10 GB). It is there to
keep the cache from filling the disk, not to keep a share of a large
disk free: 10 GB leaves the system room to work on any disk, a small
disk still keeps 2 GB, and a 100 GB disk keeps 5 GB as before. After
Effects has only a maximum cache size and no reserve; a clamp keeps
the floor for small disks, where a fixed 10 GB would take most of the
space.

Writes the limit refuses cost no work: DiskCache::accepts_writes (one
atomic read) is checked before a frame is converted (put_disk_frame)
and before frames and layers are compressed (put_frame, put_frame_f16,
put_layer). The frame workers spent about 8 ms per frame and 14 ms per
layer on encodes that were then refused. persist_missing returns at
once at limit 0 instead of converting the RAM frames on every idle pass.

Settings > Disk shows the limit in effect in MB under Maximum Disk
Cache Size, with a note when free disk space lowers it
(settings.disk.cacheLimit).

* Disk cache: refuse writes below 64 MB of room, show this disk's reserve

Review follow-ups to the reserve fix:

- accepts_writes also refuses while free disk space leaves less than
  64 MB of room (a limit the setting asks for is always used): a few
  frames would fill it, and the rest would be compressed only to be
  refused.
- While writes are refused the idle passes ask for no repaint, so the
  UI keeps a frame coming every 5 s: the free space is read again and
  the RAM frames are written once there is room.
- Settings > Disk names the space kept free on this disk (the reserve
  the last reading applied; the rule from MIN/MAX_DISK_RESERVE when it
  is unknown), and the "no room" note follows accepts_writes.
- The settings.disk.cacheLimit automation label is machine-readable
  and the same in every language: limitMb, maxMb, reserveMb,
  spaceLimited, writes.
- cargo fmt.

* Disk cache: follow 3D model sources in the comp's reach

The reach walk followed comp, footage and solid sources but not
LayerSource::Model. An expression reading a model layer's source
(thisComp.layer("Model").source.name) left the model item out of the
comp's content key, so renaming the model kept the key and the old
frames on disk were served. Every source item is followed now
(LayerSource::item), which also counts the model when no expression
reads it (Advanced 3D draws it).

The same omission is fixed in the RAM preview's content walk
(frames.rs) and in the layer key's walk of referenced layers
(cache/dependencies.rs).

* Disk cache: a render's drop generation travels with its reads; no disk entries for Expression Selectors

Drop generation per render: the layer cache recovered a buffer's drop
generation from the latest miss of its key, so a second render of the
key that started after File > Reload Footage lent its generation to a
render still running from before, and that render's buffer (the file as
it was) was written after the sweep. Now Renderer::recorded starts each
buffer's read log at the store's drop generation before anything is
read (ReadLog::starting_at); ReadFiles carries the earliest generation
through merges, so derived buffers (styles, the blend-space copy, the
flattened layer) and frames that use a buffer from memory carry it too.
LayerCache::insert keeps a buffer made of a file dropped since its
reads began neither on disk nor in memory; a list that doesn't say
counts as made before every drop. DiskCache::dropped_since takes the
list's own generation too. missed_at keeps only the miss time.

Expression Selectors: the layer key evaluates a text Expression
Selector's amount for the first character only, so a per-character
dependency such as textIndex == 1 ? 0 : footage("ref.png").width was
not in the key, and after the footage item's width changed a reopened
session reused the old layer entry. A text layer with an Expression
Selector now marks its reads unkeyed (ReadFiles::is_unkeyed), which
merges like the generation; such buffers stay in memory and are never
written to disk. Frame entries are unaffected: the comp's frame key
hashes what the expressions name. The equality and hash of ReadFiles
ignore both new fields.

* Disk cache: never serve a RAM layer buffer made before a drop of its files

A render that passed the drop check could sit in put_layer's encode
while Reload Footage cleared the layer cache and dropped the file, then
publish its old pixels to memory after the purge; get, and a fresh
render's insert, then served them. The disk-hit promotion went through
the same insert_mem.

Every way the cache hands out a RAM buffer (get, the kept-first path of
insert, an existing entry in insert_mem) now checks the entry's files
against the store's drops from their own generation (ReadFiles::since):
an entry made before a drop of one of its files is forgotten and the
lookup misses, so a fresh render's buffer replaces it. insert_mem also
re-checks a buffer just before publishing it.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:30:59 +01:00
Valentin Klein 514e16c027 .aep import: effects without definitions borrow them from another instance (#3223)
Refs #3170 (the Color Control part).

Some files have no project-level effect definition list (EfdG); it was seen in
17 projects, all from inside After Effects-made Motion Graphics templates. In
those, one instance per effect type keeps the full parameter definitions
(parT) and every other instance of the type has an empty parT. The others
were reported "without readable parameter definitions" and dropped (129
Color Controls of one template). The definition index now falls back to the
parT of the first instance of the same match name that has its own
(DefinitionSource::OtherInstance). Values still come from each instance's own
records; a value record that borrowed definitions do not name is reported
instead of vanishing.

The "Color ... holds an unreadable value" report came from 0.21.1, which read
only written records (tdsb 1). Reading default-state records (tdsb 3, #3126)
probably fixes it, but that is not confirmed for Color Control; a synthetic
test now pins a default-state colour record.
2026-11-08 01:57:47 +01:00
Valentin Klein 99e24f5867 .aep import: image sequences without a filename prefix or with Force alphabetical order (#3213)
* AEP import: read image sequences without a filename prefix and with Force alphabetical order (#3174)

After Effects leaves out an empty filename prefix, so only one Utf8 (the
suffix, such as `.png`) follows the alias. The single part is now placed by
the first file name stored in `opti`: a name starting with it has it as the
prefix, one ending with it as the suffix; without that evidence a part
starting with `.` is the suffix and anything else is not read.

A sequence imported with Force alphabetical order stores its files in
`LIST StVc` (`StVS` with the count as a little-endian u32, then one Utf8 per
file name) instead of prefix and suffix; that list now replaces the
numbering. A list with a name that is not a plain file name (a separator, a
colon, `.` or `..`) is not read, since the names are joined to the folder.

A folder alias whose naming still cannot be read is reported as an import
loss instead of silently linking the folder. docs/aep-format.md describes
the empty prefix and StVc/StVS.

* AEP import: a listed sequence file name with a control character such as NUL is not read (#3174)

A NUL or another control character in a Force alphabetical order file name
now makes the whole list unreadable, like a separator or a dot directory, so
the import reports the naming instead of linking files that cannot exist.

* AEP import: a sequence file list without a readable count is not read (#3174)

After Effects writes the StVS count before the names. A StVc list without
StVS, or with one shorter than its u32, is now reported as unreadable naming
instead of taking every listed name.

* Doc comments for the functions this PR touches
2026-11-08 01:33:26 +01:00
bb2d2005ce Layer: Un-precompose a selected pre-comp layer back into its parent composition (#3081)
* Layer: Un-precompose a selected pre-comp layer back into its parent composition (#2918)

layer.unprecompose copies the nested composition's layers into the parent at the pre-comp
layer's place with timing and position carried over, and refuses what it can't keep exactly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Un-precompose: refuse a pre-comp layer with Solo or Guide on

A soloed pre-comp layer hides the comp's other layers and a guide layer is
left out of renders; the expanded copies carry neither switch, so the picture
would change. Listed with the other blockers, in the command description and
the agents.md Cookbook entry; the refusal test covers both switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn instead of refusing, only a locked layer stops it

A locked pre-comp layer is still refused, with or without `force`. Every
other former refusal reason is a warning: without `force`,
`layer.unprecompose` returns an error that lists the reasons and changes
nothing. `dryRun` returns `{dryRun, warnings}`. With `force` the layer is
unpacked in one undo step and the result carries `warnings`. Whatever the
pre-comp layer itself carried is dropped, and other layers' parent, track
matte and layer-property links to it are removed.

Layer > Un-precompose in the UI lists the reasons in a prompt with
Unpack Anyway / Cancel (`dialog.unprecompose.force` /
`dialog.unprecompose.cancel`). A layer without reasons unpacks at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn about expressions that refer to the pre-comp layer

An expression on another layer of the comp that names the pre-comp layer
would no longer find it once the layer is unpacked. The expression can
name it in quotes or call `layer(<index>)` with its index, bare or on
thisComp; another comp's comp("…").layer(…) is not counted. Each such
property is now listed once as a warning. Like the other warnings,
`force` unpacks and `dryRun` lists it. The expressions are left as
written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn about lost frame sampling and Position expressions, keep markers in order

Review follow-up (CodeRabbit on #3081):
- A nested comp that preserves a frame rate other than the parent's
  samples its layers on its own frames, and the expanded copies would
  lose that. Motion-blurred nested layers under a different shutter
  would also blur with the parent's shutter. Both are now warnings.
- When the pre-comp layer's Position differs from its Anchor Point, the
  move is added to the stored Position of the unparented nested layers.
  An unparented layer whose Position, or a separated axis, has an
  expression may ignore that move, so it is now a warning.
- Copied comp markers are sorted into time order with the comp's own.
- New tests: a 10 fps nested comp with Preserve Frame Rate in a 30 fps
  comp, a different shutter, a Position expression with and without a
  move, and nested matte and layer-value remapping with shifted markers
  merged in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn when an expression's layer numbers shift; the prompt keeps its target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: a shorter renumbering warning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-07 00:56:54 +01:00
52bca964f5 Image sequences: a gap in the numbering holds the previous frame, as After Effects (#3137)
* Image sequences: a gap in the numbering holds the previous frame, as After Effects

Split out of #1876 (originally 7850cbf4), because it reverses the colour bars
documented in #297 and needs its own discussion.

After Effects 2026 shows the last existing file across a gap in a sequence's
numbering (files 1, 2, 4, 5 show frame 2 at frame 3; read back from After
Effects). EffectCraft drew colour bars there. The media pool now holds the
previous file (Footage::sequence_file_held); colour bars remain only where no
file precedes the frame. Missing-frame reports on import are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Image sequences: hold the first file of the preceding number across a gap; fix the import warning

A gap now holds the file sequence_file picks for the preceding number, so
files sharing a number do not switch at the gap. The Report Missing Frames
warning says the previous frame is held instead of "shown as placeholders".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Image sequences: Interpret Footage says a gap holds the previous frame

The Interpret Footage note for a numbered sequence with gaps still said the
missing frames are shown as colour bars; the media pool holds the previous
frame there now. The note, its comment and two test doc comments say so,
and a forms test checks the note's wording.

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>
2026-11-07 00:40:24 +01:00
23c1f33c88 Echo: Blend, Composite In Back and In Front match After Effects (#3132)
* Echo: swap Composite In Back and Composite In Front (#3124)

After Effects 2026 renders of the importer sample (opaque test card with a
moving square, one echo at -1/30 s) show Composite In Front keeping the
layer on top (the output equals the current frame) and Composite In Back
putting the echo over the layer. EffectCraft had the two the other way
round, which made ~0.5 % of the pixels differ at the moving edge. The CPU
and GPU (fxn_echo) arms are swapped together.

A regression test rebuilds row 130 of the test card and checks Add,
Maximum, Minimum, Screen and both Composite operators against the measured
After Effects row. Blend is left unchanged: the sample (one echo, intensity
1, decay 1) cannot tell its normalisation apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Echo: Blend sums the weighted frames like Add (#3124)

New After Effects 2026 renders (our own 1110-extra sample, 16-bit PNGs,
opaque test card with a moving square) show that Blend does not normalise:
- 3 echoes at intensity 1 and decay 1 give four times the layer.
- 2 echoes at intensity 0.5 and decay 0.5 give alpha 0.875
  (0.5 + 0.25 + 0.125).

EffectCraft divided by the number of frames. The division is gone on the
CPU and in fxn_echo_fin on the GPU.

The same sample pins Composite In Back with three echoes: each older echo
goes over what is there with its weight as opacity, so the oldest is on
top, as the branch already does. Composite In Front with three echoes
equals the current frame.

A regression test checks four measured 16-bit rows within 4 codes (24 on
square-edge pixels): Blend 3/1/1, Blend 2/0.5/0.5 with alpha, and both
composite modes at 3 echoes, -0.08 s, decay 0.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Echo: doc comments on the CPU and GPU entry points

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>
2026-11-07 00:24:10 +01:00
719b9170dd 3D rotation order, keyed Orientation, linear-to-eased keyframes and Timewarp behave as in After Effects (#1876)
* 3D: Orientation and X/Y/Z Rotation turn Z first, keyed Orientation slerps, as After Effects

After Effects composes a 3D layer's (and a camera's) Orientation and its X/Y/Z
Rotation as Rx * Ry * Rz each, Orientation before the axis rotations; EffectCraft
used Rz * Ry * Rx. Measured on synthetic AE 2026 cases: Orientation [10,20,30]
(488) was 5.2 % of the frame off and now matches to the anti-aliased edge (0.03 %);
X/Y/Z Rotation 10/20/30 (800), Orientation + X Rotation (801) and one-node
cameras (804, 805) likewise.

Keyed Orientation moves along the shortest arc between the keys (quaternion
slerp, eased in time, angles written in [0, 360)): keys [0,0,0] -> [10,20,30]
read back [6.2643, 9.3531, 15.2190] half-way where EffectCraft gave [5,10,15];
[0,0,0] -> [60,80,40] linear and eased match AE's read-backs within 0.002 deg.

lookAt() returns AE's angles (z axis on the target, x axis level; two AE
read-backs within 1e-3 deg). The camera tracker, VR editor, Look At and orbit
commands use the new decomposition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keyframes: a linear side next to an eased side has 16.67 % influence, as After Effects

In a segment that is Bezier on one side and linear on the other, the linear
side keeps the segment's average speed, but with the 16.67 % influence After
Effects shows for linear keys in the Keyframe Velocity dialog (and reports to
scripts), not 33.3 %. Measured on an After Effects 2026 time-remap render (a
square at x = 50 + 455 * remap): out of a key at 2 s (3.5, speed 0, influence
50 %) into a linear key at 4 s (0.5), AE plays 3.290 at 2.5 s and 2.657 at
3 s; EffectCraft played 3.308 and 2.629 (8 and 13 px off), now the same
square positions to the pixel.

LINEAR_SIDE_INFLUENCE is used by evaluation (scalar, per-dimension, paths and
spatial), side_ease (Graph Editor, velocity dialog, keyIn/OutTemporalEase) and
the Lottie export, so exported easing plays as EffectCraft does. Segments
linear on both sides stay straight lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Timewarp: retime the layer's source without the effects above it, as After Effects

After Effects' Timewarp reads the layer's source (with its masks) at the
retimed times and ignores the effects applied before it: a red solid with a
Gradient Ramp and then Timewarp (Speed 25) renders as the red solid in AE
2026 (EXPORTER e2 read-back). EffectCraft retimed the layer with its
effects (mean difference 127.5/255 over the comp), now identical (0, CPU and
GPU). Echo, Posterize Time and the other time effects keep reading the
effects before them, as measured before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Image sequences: a gap in the numbering holds the previous frame, as After Effects

After Effects 2026 shows the last existing file across a gap in a sequence's
numbering (case 860: files 1, 2, 4, 5 show frame 2 at frame 3; VERIFY
read-back). EffectCraft drew colour bars there. The media pool now holds the
previous file (Footage::sequence_file_held); colour bars remain only where no
file precedes the frame. Missing-frame reports on import are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: keyed Orientation uses LINEAR_SIDE_INFLUENCE for linear sides; real NaN assert

evaluate_orientation still gave a linear side (and the unreachable missing-key fallback)
1/3 influence while segment_value, side_ease and the Lottie export use
LINEAR_SIDE_INFLUENCE (1/6), so a keyed Orientation next to an eased key moved on a
different curve than every other property (CodeRabbit on #1876). New test:
orientation_linear_side_next_to_an_eased_side_matches_a_scalar (both directions).
The always-true NaN assert in keyed_orientation_slerps_as_after_effects now checks that
a non-finite key comes back as given; Property::value_at says why the match_id
"orientation" routing is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Format keyframe orientation test (cargo fmt)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Move the image-sequence gap change out of this branch

The gap change (hold the previous file instead of colour bars) reverses
#297's documented behaviour and goes to its own PR, as requested in review.
pool.rs, sequence.rs, the footage test and docs/footage.md / docs/agents.md
are back to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-07 00:03:52 +01:00
FredAppsandClaude Opus 5.5 e284758683 CI: run the gate on macOS on pushes to main (#3177)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:03:41 +01:00
c5bb8bfda4 SAM 2 mask decoder: transposed convolutions as matrix product plus pixel shuffle (#3031) (#3035)
* SAM 2 mask decoder: transposed convolutions as matrix product plus pixel shuffle (#3031)

Both transposed convolutions of the mask decoder have a 2x2 kernel, stride 2
and no padding, so each input pixel owns its own 2x2 output block. That is one
matrix product plus a pixel shuffle, which is about twice as fast on the CPU
as candle's conv_transpose2d. The first convolution also takes the decoder's
key tokens directly, without the transpose and copy to an image.

The candle path stays in the code for the tests. They compare the two to
1e-6 (relative to the largest output value) on the decoder's real sizes,
odd and degenerate sizes, large values, zeros and a single hot pixel, check
that other kernels are refused, and compare a whole decoder forward pass
(single and multi mask) on random weights.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* SAM 2 decoder test: bound the weight discovery loop

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 23:38:34 +01:00
1ac3315ac4 SAM 2 tracking: prompt frames in frame order, no second fetch for the size check (#3030) (#3034)
* SAM 2 tracking: prompt frames in frame order, size-check frame reused (#3030)

With several objects prompted on three or more frames, the prompts went to
the tracker object by object. The tracker keeps features for two frames, so
every prompt after the first object fetched and encoded its frame again.
Each prompt frame was also fetched once only to check its size and then
fetched again for the encoder.

Prompts now go to the tracker in frame order (then object order), and the
frame fetched for the size check is handed to the tracker the first time it
asks for that frame (up to 256 MB of pixel data kept; beyond that the frame
is fetched again as before). Objects do not influence each other, so the
results are unchanged.

Tests cover the prompt order, one host fetch per prompt frame, the order of
several prompts of one object on one frame, and the memory cap.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* SAM 2 tracking test: the held-frame cap is cumulative

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 23:22:19 +01:00
a17e1f20d6 av1enc: motion search with visited set and early-exit SAD, fixed-size SATD, region-only snapshots (#2406)
* av1enc: motion search with a visited set and early-exit SAD, fixed-size SATD, region-only BlockDecoded snapshot

- motion_search remembered nothing: the diamond search evaluated the previous
  centre again and every SAD ran to the last row even when it had already
  lost. Full-sample and sub-sample positions are now tracked in a visited
  list (costs are deterministic, so a position that was evaluated cannot beat
  the best cost again), and the SAD stops after a row once sad + mv cost can no
  longer be below the best cost. The stop test is the same f64 expression as
  the strict "<" the callers apply, and it is monotone in the SAD, so the
  chosen vector is the same.
- satd runs with a fixed block size (satd_k::<4> / satd_k::<8>): the
  difference block is a [[i32; K]; K], the vertical butterflies work on whole
  rows at once, and the old per-column gather is gone. The sum is an integer
  sum of the same values.
- A snapshot copies only the BlockDecoded cells of the block area (at most
  16x16 per plane), the only flags coding inside the block can change, instead
  of the whole 3x35x35 array.

The encoded stream is unchanged. A unit test compares the new SATD with the
previous implementation for 4..32 sample blocks at 8 and 10 bit.

Fixes #2387

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* av1enc: tests for the region-only BlockDecoded snapshot and the bounded SADs

- `snapshot_restore_brings_back_every_blockdecoded_flag`: for every block
  size of a 64x64 superblock (4x4 .. 64x64) at every aligned position in two
  superblocks, from an all-clear and a random flag state, marking every
  sub-block of every size inside the block and restoring gives back the whole
  3x35x35 array, so the 16x16 region covers every flag coding can set and
  leaves the neighbours alone.
- `bounded_sad_stops_only_once_the_cost_cannot_win`: `sad_ref` and
  `sad_bounded` return the full SAD whenever `sad + lb < bound`, and
  otherwise a partial sum that already loses the strict `<` of the callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 22:58:00 +01:00
68a637adb9 opusenc: memoise SILK rate-loop trials, ring buffer for noise feedback (#2399)
* opusenc: memoise SILK rate-loop trials, ring buffer for noise feedback

Rate loop: each probe clones the encoder and the range coder and codes the
whole frame, about 7 trials per frame. The hard-limit check recomputed the
trial of the chosen offset and the final step coded the frame a third time.
Keep every trial (offset, bits, encoder state, range coder): the hard-limit
check now hits the memo and the final step adopts the memoised state and
coder. If the chosen offset is somehow not in the memo it is coded as before.

Quantiser: the noise-feedback history was shifted with copy_within for every
sample (the quantiser runs about 7 times per frame). Keep it in a
double-length ring so the 16 most recent errors are one contiguous slice;
the dot product reads the same pairs in the same order, and the history is
written back to the channel at the end.

The packet streams are byte-identical to before (cmp on 24 configurations
of 90 s: speech-like and music-like input, mono and stereo, SILK NB/MB/WB
and hybrid SWB/FB).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* opusenc: noise-feedback ring as a helper type, equivalence tests for the SILK rate loop

`quantize` was at cognitive complexity 31 on current main (limit 30). The
double-length ring for the noise-feedback history moves into `NfRing`
(push / window / to_array), and the end-of-frame bookkeeping of `encode`
into `end_frame`.

Tests: the ring matches a shifted history over 100 pushes, and an encoder
that adopts the memoised trial produces the same bytes, noise-feedback
history and LPC state, frame after frame, as one that codes every frame
again at the chosen offset (the step before the memo), for 8/12/16 kHz,
mono and stereo, with and without a tight hard limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 22:35:42 +01:00
46f04d1a2d Subspace Warp: a symmetric QR eigen solver and a banded mesh solve make plan() 5x cheaper (#2319) (#2332)
* Subspace Warp: QL eigen solver and banded mesh solve (#2319)

plan() spent 43-96 % of its time in the cyclic Jacobi sym_eigen, whose
absolute stop test never triggers on Gram matrices of magnitude ~1e8, and
then ran a dense Cholesky on a mesh normal matrix of bandwidth ~30.

Use Householder tridiagonalisation plus implicit QL inside subspace.rs
(only the leading subspace is used, so basis and sign do not matter), and
accumulate and factor the mesh normal equations in lower-band storage,
without a Vec per data row. camtrack/linalg.rs is untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Subspace Warp: replace the QL eigen solver with a clean-room symmetric QR

The previous sym_eigen_ql followed a well-known published tred2/tqli listing
too closely for the clean-room rules. It is replaced by sym_eigen_qr, written
from the textbook method (Golub & Van Loan, Matrix Computations, ch. 8) in
its own structure:

- householder_tridiagonal: explicit reflectors with the symmetric rank-2
  update B -= v w^T + w v^T (p = beta B v, w = p - (beta p^T v / 2) v), Q
  accumulated as rows of Q^T.
- wilkinson_qr_step: one implicit QR step with the Wilkinson shift, Givens
  rotations chasing the bulge from the top of the unreduced block down.
- sym_eigen_qr: deflation of negligible off-diagonal entries and the step
  driver, split so each function stays under the complexity limit.

It returns None for non-finite input, a size mismatch or no convergence
within 30 n steps; smooth_trajectories then falls back to the Jacobi
sym_eigen, so it can never loop for ever or be worse than before.

Tests: random symmetric matrices (n = 1..61) against Jacobi eigenvalues,
residual |A v - lambda v| and V^T V = I; identity, repeated 3x3 blocks, zero,
1x1, 0x0, rows scaled from 1e-8 to 1e8 and a 1-2-1 tridiagonal; NaN, infinity
and size mismatch return None; the leading-subspace projector of Gram-like
low-rank matrices matches Jacobi. An ignored benchmark times plan() on a
300-frame synthetic parallax shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 22:11:22 +01:00
392e827626 Roto Brush: propagate on the band bounding box (#2335)
* Roto Brush: propagate on the band bounding box

propagate() ran the signed distance, gauss, graph cut, model fit and
clean over the whole frame. Work on the crop of the band bounding box
(|sd| <= r plus the core, padded) and paste the labels back; fall back
to the full frame when the crop is not smaller. warp_prior stays
full-frame.

Fixes #2323

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Roto Brush: band crop padded by the prior blur, models fitted on the full frame

Review follow-up for the band bounding-box crop in propagate:

- The crop pad is now the reach of the prior blur (three box passes of
  matting::gauss, exposed as matting::gauss_reach) instead of 1 px, so every
  band pixel sees the same blur window as on the full frame.
- When the previous frame has no colour models they are fitted on the full
  frame before cropping, as the full-frame path does.
- The free pixels and the clean seeds are cut out of the full-frame signed
  distance instead of being recomputed on the crop; the seeds equal
  core_of(prior.soft), which saves one signed distance per frame.
- The full-frame path and the crop share recut_band/cut_band; full frame is
  just the whole rectangle. A frame with no band and no core (no
  foreground) skips the cut, which was a no-op.
- The w - 1 / h - 1 underflow on zero-sized frames is gone (band_rect
  returns None); regression test for 4x0, 0x4 and 0x0 (panicked before in
  debug builds).

Tests: crop vs full frame labels on a disk, a disk at the frame corner, a
donut, r = 2 and r = 3 with prior edges wider than the band, and no
foreground, each with fitted, given and equal colour models; the padded crop
blurs the prior like the full frame at every band pixel while a 1 px pad
does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 21:47:00 +01:00
69114a8294 Roto Brush: per-split scratch vectors in the parallel distance transform (#2333)
* Roto Brush: reuse scratch vectors in the distance transform

matting::distance allocated four vectors per column plus a staging copy
and three vectors per row. Run the columns and rows through four reused
scratch vectors instead; the dt1 arithmetic is unchanged.

Fixes #2324

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Roto Brush: keep the distance transform parallel, with per-split scratch

The previous commit made the column and row passes serial, which was measured
on one thread only. Both passes are parallel again: the column pass runs on a
transposed buffer so every column is a contiguous chunk, and rayon's
for_each_init gives each work split one set of scratch vectors instead of
four allocations per line. The square root is folded into the row pass.

A test keeps the old per-line implementation and checks that the output is
bit-identical on a disk, its inverse, 1xN, Nx1, all-true, all-false, seeds on
the frame edge and a sparse 333x211 pattern. An ignored benchmark prints the
1080p median and the rayon thread count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Roto matting: size the distance-transform scratch per pass (columns h, rows w)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 21:22:40 +01:00
FredAppsandClaude Opus 5.5 4d21269adb Display pass tests: a virtual GPU gets the software tolerance (#3182)
On a hosted macOS runner the_shader_is_the_reference found 168 of 16384
bytes one step off, just over the 1 % a real GPU is allowed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:26:58 +01:00
FredAppsandClaude Opus 5.5 4465c0ea10 EXR golden test: compare samples near, not bit for bit (#3181)
* EXR golden test: hash samples near, not bit for bit (temporary: print the new goldens)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* EXR golden test: the new near hashes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:23:50 +01:00
FredAppsandClaude Opus 5.5 5047d8b5e7 Posterize on the GPU: divide by the levels exactly, as the CPU does (#3179)
Native division isn't correctly rounded on real GPUs, so
posterize_matches_the_cpu_on_every_8bpc_pair failed on NVIDIA and Apple
GPUs while passing on the software renderer hosted CI uses.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:04:21 +01:00
ca1ff9cfc2 .aep import: map the Cryptomatte plug-in to EffectCraft's Cryptomatte (#3109)
* AEP import: map the Cryptomatte plug-in to EffectCraft's Cryptomatte

The importer reported every instance of the free Cryptomatte plug-in
(match name `Cryptomatte`) as a lost effect. It now becomes
`ec.3d.cryptomatte`:

- CryptomatteData decodes the plug-in's Settings data (its own
  little-endian layout, documented in docs/aep-format.md 3.7): the
  Cryptomatte layer name and the selection text, both carried verbatim.
- Output maps to Display / Matte Only (Colors -> Colors, Matted RGBA ->
  Matte, Matte Only -> Matte Only). Matted Colors, Selection Mode when on,
  keys or expressions and anything else stored are reported, and
  unreadable data is reported as damaged.
- EffectCraft's Cryptomatte gains a Layer Name text parameter. A layer the
  footage has under exactly that name wins; otherwise the Layer popup
  decides as before. The CPU, GPU and pick paths share one resolver
  (AuxChannels::resolve_crypto_layer_named). Without it, plug-in layer
  names such as Crypto_Material would silently fall back to the first
  layer.

On a real production project this takes the effect losses from 132 to 44.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AEP import: check Cryptomatte against an After Effects 2026 sample of our own

A sample made in After Effects 2026 (plug-in 2.6.6: a fresh effect,
Selection Mode on, each Output item, with read-backs and renders)
corrected three points of the mapping:

- Selection Mode on was dropped silently: After Effects writes it as a
  default-state record (tdsb 3) holding 1, which the general reader does
  not take. The Cryptomatte step reads that record itself and reports it.
- No Output record is now the plug-in's default Colors, also for an
  instance that borrows the project's definition (every instance after
  the first), which was reported as having no reliable default and fell
  back to EffectCraft's Preview. Keyed or unreadable Output values fall
  back to Colors too, still reported.
- Matted Colors imports as Display Matte (the same matte over the layer's
  own colours) instead of Preview, still reported as an approximation.

docs/aep-format.md 3.7 gets the sampled Output default, the tdsb 3
Selection Mode record and the real fresh Settings data (an empty text of
length 1 and its MurmurHash3, twice, not cry1 and zeros); docs/aep-import.md
the render comparison with After Effects (Matted RGBA matches; Matte Only
differs in alpha; Colors with no layer chosen shows another layer).
Tests: the exact fresh Settings bytes, tdsb 3 Selection Mode, borrowed
definitions, and an opt-in test against the sample once it is in the
corpus (aep/cryptomatte.aep, ignored until the corpus pin includes it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AEP import: write Cryptomatte's popup items in the test as After Effects does

The injected Cryptomatte definition wrote `pdnm` and `fnam` as `LIST`
chunks. After Effects writes them as plain chunks whose body is chunks
(`NESTED_IDS`), and the reader only looks there, so the tests never read
Output's item names: they passed through the fallback for a definition
with four items and no `pdnm`, which uses the plug-in's own list.

The harness now writes both as After Effects does. New tests check that
the injected definition reads back with its items and instance name,
that Output is mapped by the item the stored index names (items in
another order, case, an unknown item), and that the fallback without a
`pdnm` still works. With the old `LIST` form, or with the mapping
ignoring the items, the new tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* AEP import: check Cryptomatte's layer and selection against presets of our own

The After Effects 2026 sample 1400 now has two preset compositions that choose a layer and
pick one object (box_red on CryptoObject) or one material (mat_paint on CryptoMaterial).
CryptomatteData reads both as they are; the picked tokens are the EXR manifest's hashes,
which EffectCraft's selection grammar reads the same as the names.

- The u32 at 108 is MurmurHash3 of the selection text with its NUL (three texts).
- A unit test pins the 127-byte preset data; the sample test now covers all eight
  compositions with their layer and selection.
- Render check: EffectCraft's matte covers exactly the pixels After Effects draws as
  selected, edge coverage within 0.0002. docs/aep-format.md 3.7 and docs/aep-import.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cryptomatte import: report damaged Settings and Selection Mode records

Review follow-ups for #3109:
- CryptomatteData refuses a selection text without its NUL and checks the zero byte
  at 112 for an empty text too.
- A Settings record without its data, and a Selection Mode record whose value cannot
  be read, are reported as damaged data, not taken as the default.
- Layer Name is the effect's last parameter, so the earlier parameters keep their
  indexes and upgraded instances have the same order as new ones.
- A Cryptomatte layer name is matched as given before it is trimmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cryptomatte settings: require the selection's NUL to end its declared length

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cryptomatte: match rank planes by the exact layer name

crypto_rank_planes looked planes up with a case-insensitive whole-name match, so
layers Crypto_A and crypto_a shared planes. The layer prefix now matches
case-sensitively; component letters still match in either case.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Format

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* AEP: the exporter knows Extra::Cryptomatte; rustfmt

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>
2026-11-06 19:36:42 +01:00
48c8137835 Accessibility (gap 25), slice 2: Info, Flowchart, Script Console and the 3D Camera Tracker block named, described and keyboard-operable (builds on #1339) (#1341)
* #1335: accessibility slice 2: Info, Flowchart, Script Console and the 3D Camera Tracker block named, described and keyboard-operable

The audit gets read-outs, graphics and logs as described items (a name is required in a fixed panel), dedicated maximized scenarios for the panels the shared loop squeezes, and minimum counts for the new zero panels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Review: Enter/Space on a favourite star only toggles it; keyboard activation of a property label; settings page scrolls to focus; sharper UI test assertions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Refresh the accessibility audit baseline after merging upstream/main

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* a11y audit: hold the machine-dependent Media Browser scenario to its upper bound

The Media Browser opens on the home folder, so a runner with fewer files finds fewer controls and
the 'improved, lower the entry' check failed in CI. The entry stays an upper bound.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Merge upstream/main and updated feat-gap25-a11y-audit into feat-gap25-a11y-panels-2

Brought in the updated feat-gap25-a11y-audit (which already contains upstream/main). Conflict: crates/ui-egui/src/menus.rs, which upstream split into menus/*.rs. Took the slim module and re-applied this branch's Flowchart hunks where the code now lives: nudge() skips the Flowchart (menus/invoke.rs) and Enter is left to the Flowchart in handle_shortcuts (menus/shortcuts.rs).

Also removed a duplicate MACHINE_DEPENDENT const in tests/ui_accessibility_audit.rs that the two branches both added. The audit BASELINE still matches the measured findings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Flowchart: split show() to pass the stricter clippy limits from main

Main now denies cognitive_complexity (30) and too_many_arguments; the
merged flowchart panel exceeded it. The keyboard navigation, the
key-to-node mapping, pan-into-view, node commands, the accessible node
name and the option params move into small helpers with unchanged
behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Camera tracker a11y test: wait for the analysis result instead of one frame

The test pressed Enter, ran one frame and expected to see the background job.
On a fast runner the job starts, ends and is written back inside that frame,
so the state was never seen. Undo the first analysis, press Enter, and wait
(bounded) for the new analysis step in the undo history.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Accessibility: one hours range for the accessibility pass in ui-parity.md; test that Flowchart automation labels stay English

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Flowchart toolbar: keep the English label for the automation registration

The translated text is shown and spoken; the automation label stays English.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-06 19:12:07 +01:00
329ba63f8d Timeline: hover texts for the icon-only column headers and layer switches (#3096)
* 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>
2026-11-06 18:55:35 +01:00
FredAppsandClaude Opus 5.5 fb62b9d7e3 Project panel: take the Name column's fit out of show() (#3173)
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>
2026-11-06 18:27:42 +01:00
Valentin KleinandClaude Sonnet 5.5 519769299f Cryptomatte: the Layer popup lists the footage's Cryptomatte layers (#473) (#3020)
* 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>
2026-11-06 17:38:13 +01:00
Valentin KleinandClaude Sonnet 5.5 fdcc715829 Viewer exposure on the full float range of 32 bpc frames (#2937)
* 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>
2026-11-06 17:37:23 +01:00
Valentin KleinandClaude Opus 5.5 8948a7cdc9 Masks: set the corner roundness of a Rounded Rectangle mask (#3077)
* 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>
2026-11-06 17:37:17 +01:00
Valentin KleinandClaude Opus 5.5 be8b5fd8d4 Layer: Quick Set Anchor, nine anchor points with Position compensation (#3080)
* 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>
2026-11-06 17:37:11 +01:00
Valentin KleinandClaude Sonnet 5.5 9a32a51793 Viewer: Alt-drag duplicates the dragged layers (#3076)
* 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>
2026-11-06 17:37:05 +01:00
Valentin KleinandClaude Opus 5.5 bb920d68ec Project panel: the Name column fits long names and resizes (#3094)
* 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>
2026-11-06 17:36:59 +01:00
Valentin KleinandClaude Sonnet 5.5 43a47a5d6a Timeline: one selection box spans the layer list and the time graph (#3075)
* 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>
2026-11-06 17:36:54 +01:00
Valentin KleinandClaude Opus 5.5 7051fc1f58 Color Stabilizer and Linear Wipe: match After Effects (sample positions, feather) (#3135)
* 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>
2026-11-06 17:36:48 +01:00
Valentin KleinandClaude Opus 5.5 93cce09f3c .aep import: name the After Effects version in the report; 25.x and 24.x copies import like 2026 (#3136)
* 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>
2026-11-06 17:36:41 +01:00
Valentin KleinandClaude Opus 5.5 ab3674cc2b .aep import: map HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer (#3119)
* 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>
2026-11-06 17:36:34 +01:00
Valentin KleinandClaude Opus 5.5 fee50dbfd1 Scripting: After Effects match names for 14 effects and Casts Shadows (#3122)
* #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>
2026-11-06 17:36:28 +01:00
Valentin KleinandClaude Sonnet 5.5 9408d70da0 Disk cache: stamp footage files for the salt off the UI thread (#3106)
* 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>
2026-11-06 17:19:27 +01:00
Valentin KleinandClaude Opus 5.5 9fc73122ea Home screen: leave it when a menu command opens a composition (#3092)
* 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>
2026-11-06 17:19:21 +01:00
Valentin KleinandClaude Sonnet 5.5 549a4d0540 Import: EDL clips without a SOURCE FILE link the file beside the list (#2836) (#3019)
* 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>
2026-11-06 17:19:14 +01:00
Valentin KleinandClaude Sonnet 5.5 ae068ff931 EXR export: write the output space's chromaticities attribute (#3038)
* 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>
2026-11-06 17:19:05 +01:00
Valentin KleinandClaude Sonnet 5.5 ee7c51740d effects: Write-on and Stroke Brush Size goes up to 200 px (#2980) (#3021)
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>
2026-11-06 17:18:58 +01:00
Valentin KleinandClaude Opus 5.5 0f94a1f983 Skip the startup update check when started for an agent (#3097)
* Skip the startup update check when started for an agent

An app started with --control or EFFECTCRAFT_CONTROL_PORT still checked
for updates at start, and the modal "newer version available" prompt
blocked agent runs until a person dismissed it. Agent launches already
skip crash recovery and the Home screen; now they skip the startup update
check too. Help > Check for Updates is unaffected (it goes through
updates_ui::check / check_requested, independent of the startup flag).

Documents what a control-port launch skips in docs/control-protocol.md and
the main.rs module doc, with a unit test for the startup decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Control protocol docs: a window-start failure still shows a blocking message box

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:18:52 +01:00
Valentin KleinandClaude Sonnet 5.5 779df34e13 contributors: set names for vangel76 (#3041)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 17:18:46 +01:00
FredApps 8be83ab559 Merge pull request #3162 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:41 +01:00
FredApps 357c72ea33 Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:01 +01:00
ash 7fc1be0bf2 Report offline footage and recover automatically when media returns (#3161)
* 3118: report and recover offline footage consistently

* 3118: preserve manual reload and refresh data watch paths

* 3118: document availability and reload contracts
2026-11-06 11:38:57 +01:00
Valentin Klein ce9eaec650 Add After Effects project export (#2594)
Add After Effects project export with chunks built in code, explicit loss reporting, engine and UI integration, and regression coverage.
2026-11-06 09:23:16 +01:00
ash bc4b96a5fc Reveal newly created mask controls in the Timeline (#3149)
* 3145: Reveal newly created mask controls in the Timeline

* 3145: Document mask reveal contracts and regression cases

* 3145: Make stale-event regression cases exhaustive
2026-11-06 08:53:04 +01:00
FredApps 3102b6e632 Merge pull request #2915 from vangel76/perf/exr-output-options
Render Queue OpenEXR: choose compression and half or 32-bit float
2026-11-06 02:26:23 +01:00
FredApps e2010893d2 Merge pull request #3130 from vangel76/cryptomatte-matte-only
Cryptomatte: Matte Only puts the matte in RGB with alpha 1, as After Effects does
2026-11-06 02:26:11 +01:00
vangel76andClaude Opus 5.5 4934de892d Merge upstream/main into cryptomatte-matte-only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:53:40 +01:00
vangel76andClaude Sonnet 5.5 f9b860838c Merge upstream/main into perf/exr-output-options
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 00:49:29 +01:00