Commit Graph
97 Commits
Author SHA1 Message Date
Sean Hawkey 24df676af5 Expressions, Scripting: a layer's property numbers and match names follow After Effects (#3528) (#3529)
* Expressions, Scripting: a layer's property numbers follow After Effects

In After Effects a layer's top-level number reaches the same group on every layer of a kind:
Marker is 1, and slots the layer has not used yet (Time Remap, Motion Trackers, Layer Styles,
Data, Sets...) keep their place. EffectCraft numbered only the groups a layer holds, without
Marker, so `layer(n)` and `layer.property(n)` reached a different group, and the numbers moved
as groups were added. Rigs that read a palette by number, such as
`comp("Palette").layer(1)(4)(n)(1)` on a shape layer, read Transform instead of Effects; their
`try` fell back to the stored value and every linked colour rendered near-black, with no
expression error.

`effectcraft_project::ae_order` holds After Effects' slot order per layer kind, observed in
After Effects 26.3 from a script (`layer.property(i).matchName`) and from an expression
(`thisComp.layer(..)(i).name`) on new layers of each kind. The expression host and the
scripting model resolve a layer's numbers through it: 1 is the markers, a used slot is its
group, an unused one is an empty group (never null, so loops over `numProperties` are safe).
`numProperties` at layer level and `propertyIndex` of top-level groups are After Effects'
numbers too. 3D model and primitive layers keep EffectCraft's own numbers.

* Expressions: a layer's top-level groups by After Effects match name

`thisLayer("ADBE Effect Parade")`, as scripts such as Karton write their expressions, reaches the
group through the same table as the layer's numbers: Effects, or an empty group for a slot the
layer has not used, and `("ADBE Marker")` the markers.

* Expressions, Scripting: review follow-ups for After Effects layer numbers

- An enabled Time Remap (a top-level property) reports After Effects' number 2 as its propertyIndex.
- In scripting, an unused slot resolves to the same empty group by match name
  (`property("ADBE Layer Styles")`) as by number (`property(7)`).
- The empty group is callable like any group: `layer.property(7)(1)` is null, not a TypeError.
2026-11-12 13:40:56 +05:30
vangel76 e41d6d9d68 Merge remote-tracking branch 'upstream/main' into text-layer-name-follow-3233 2026-11-08 04:42:27 +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
vangel76 61af9e5b72 #3233: a text layer's name follows its text until the user renames it
A text layer's name followed its Source Text whenever the name started with "Text", so a user's
"Text Title" was replaced by the next text edit. The layer now records whether its name follows
the text (`name_follows_text`, optional in the project file). A new text layer named after its
text follows it; a name given at creation or by Rename (Enter, layer.rename, a script's
layer.name, Layer Settings' name) is the user's and stays; renaming to an empty name shows the
text again and follows it; committing the unchanged name changes nothing; copies keep the state.
On-canvas typing and layer.setText share the rule. The name is the text's first paragraph, which
ends at a line feed or a bare carriage return (After Effects' paragraph break).

Projects saved without the state keep the old rule for the names that followed: a name that is
empty, the text's own, or "Text"/"Text 2"... follows; any other name stays. AEP import records a
stored layer name as the user's and no name as following, and names an empty text layer without
a name "Text" instead of "Layer"; export writes no name for a following layer and keeps the name
the user gave, even when it equals the text.

Fixes #3233
2026-11-07 15:41:14 +01:00
30d6c6e1d3 Frames cache: edits keep the frames they can't reach (#2795)
* 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>
2026-11-05 10:46:39 +01:00
Valentin Klein bb78d55825 After Effects project import (.aep / .aepx); export follows (#2558)
Add After Effects project import with bounded parsing, explicit loss reports, and regression coverage.

Fetch the cleaned external test corpus at commit 97262276f625afdeeb61787e4cbb74b3f17af510 and verify each fixture against the pinned SHA-256 manifest.
2026-11-04 12:06:08 +01:00
bflatasticandClaude Opus 5.5 9e6332ea27 Merge main into #1739; keep both footage re-exports (Cineon + VIDEO_EXTENSIONS) and main's gaps.md localization row with the PR's AVI row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:44:20 +07:00
bflatastic a38328cc3a Keep the PR building with main: Layers stays off psd documents; preview merge and fmt 2026-10-30 07:11:40 +07:00
bflatastic 5c2e7ad343 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 07:11:39 +07:00
dotCooCoo 2bef8aa669 Cineon: the Cineon Converter keeps its Gamma scale
cineon_log_to_lin takes the exponent scale from its caller. Interpret
Footage > Cineon Settings passes 1 (Gamma 1 gives linear light) and the
Cineon Converter passes 1.7, as before, so the converter's output and
its default Gamma 1.7 are unchanged. The converter's Linear to Log, Log
to Log reference and GPU shader return to their earlier code.

A test checks the converter at its default Gamma and at 2.4, for all
three conversion types, against the earlier formula.
2026-10-29 06:46:51 -07:00
dotCooCoo 0acc2a4677 Cineon: Interpret Footage and the Cineon Converter share one log-to-linear function
effectcraft_project::cineon_log_to_lin does the log-to-linear conversion
for Interpret Footage > Cineon Settings and the Cineon Converter's Log to
Linear. Both raise the result to 1 / Gamma, so Gamma 1 gives linear light
and the same Gamma gives the same values in both. The converter's Linear
to Log, its Log to Log reference and the GPU shader use the same Gamma
scale, with a smallest Gamma of 0.01 / 1.7 (CINEON_MIN_GAMMA).

A test runs the converter at Gamma 1.7 (the default), 1 and 2.4 and
checks it against Cineon Settings at each code value.
2026-10-29 02:00:35 -07:00
Eduardo EspinosaandClaude Opus 5.5 e1d66a3c5c perf: share unchanged layers between undo states
Every undoable edit keeps the previous project for undo, so the comp it touches
is shared and Arc::make_mut deep-copies it: every layer and every keyframe. An
edit therefore costs time in proportion to the comp's size, and a script that
sets many keyframes in a big comp goes quadratic (a 1,600-layer build with
~100k sampled keys took ~45 minutes).

Comp::layers is now a Layers collection backed by Vec<Arc<Layer>>: copying the
stack copies pointers, and only the layer an edit changes is copied. The API
mirrors Vec<Layer>; serialized form is unchanged (a list of layers).
Comp::layer_mut and the iter_mut().filter/find call sites use find_mut /
iter_mut_where so they copy only the layers they actually change.

- prop.addKey in a 400-layer comp: 1.36 ms -> 0.16 ms per key (flat in comp size)
- the 1,600-layer script build: ~45 min -> 47 s, frames pixel-identical
- regression tests: an edit leaves the other layers shared with undo; layers
  round-trip as a plain list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkpGcKfWyCudfSsQNiWAaG
2026-10-28 22:20:23 -04:00
dotCooCoo e0dc5eb2ff Merge main and retain Cineon Settings in the extracted modules 2026-10-27 11:41:43 -07:00
tylerjenningsw 74b309e6cf Merge remote-tracking branch 'origin/main' into enhance/avi-import
# Conflicts:
#	crates/project/src/lib.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/menus.rs
2026-10-27 14:13:31 -04:00
aiongg 55ab7b556c refactor(project): move settings, comp, layer, footage and item types out of lib.rs 2026-10-26 21:49:11 +00:00
Dylan M. Taylor e821feeec4 Merge pull request #2274 from storytold/sonar/rust-s4325-s1612
sonar: method paths instead of closures (rust:S1612)
2026-10-25 16:36:06 -04:00
tylerjenningsw a2f3ac41b1 Merge main into enhance/avi-import 2026-10-25 14:26:28 -04:00
aiongg 991904888a Merge pull request #1643 from arnav-hiwarkar/fix/1641-keep-cache-outside-edit
Preview: an edit keeps the cached frames outside the layers it changes (#1641)
2026-10-24 16:27:16 -04:00
dotCooCoo 9de2c5ee8a Interpret Footage: Cineon Settings convert DPX and Cineon log scans
DPX and Cineon footage showed its log code values as stored, with no
way to convert them. Interpret Footage now has the Cineon Settings that
After Effects' help page describes: Logarithmic Conversion, the
Converted Black and White Points, the 10 Bit Black and White Points,
Current Gamma, Highlight Rolloff and Units. With Logarithmic
Conversion on, the code values of each decoded frame are converted
from printing density to the Current Gamma between the converted
points, before Separate Fields, Frame Blending, Interpret As Linear
Light and the working space use them. With it off (the default), the
values stay as stored.

file.interpretFootage and file.interpretProxy take the settings as
parameters and report them. They return an error for out-of-range
values, and for Cineon parameters when none of the items is a DPX or
Cineon file. Apply Interpretation copies the settings between DPX and
Cineon files.
2026-10-24 08:37:15 -07:00
Dylan M. TaylorandClaude Sonnet 5.5 4e9fa51f12 sonar: method paths instead of closures (rust:S1612)
Poison recovery, OsStr::to_str and char predicates only; S4325 casts are
f64 to f32 and stay.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:17:16 +00:00
aiongg d88a604e8d Merge #2041: Save: check the file's values without reparsing it in release builds (@arnav-hiwarkar) 2026-10-24 01:12:11 +00:00
skylar taylor-barrick b55cb5bc15 Merge pull request #1363 from DWAA1660/fix/1237-comp-contains-cycles
Project: comp_contains walks precomps with a visited set, not recursion (#1237)
2026-10-22 07:52:56 -06:00
arnav-hiwarkarandClaude Opus 5.5 c9323afe1d Save: check the file's values without reparsing it in release builds
- New `crates/project/src/file_check.rs`: a no-output `Serializer` that walks the value once. It refuses non-finite numbers and nesting past 127 levels, and it allocates only on error.
- `to_file_json_with_view` runs that check, then writes the file. It also refuses a project schema newer than this build, the remaining thing `from_json` would reject in a file we wrote.
- **Debug builds still reparse the text as well**, so a new kind of mismatch still shows up in tests.

The bytes written are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:34:59 +05:30
tylerjenningsw d2ae0bd503 Merge main into enhance/avi-import 2026-10-18 12:45:36 -04:00
tylerjenningsw bed49c4959 Media: import AVI (#1701)
One list of movie extensions, in effectcraft-project, with AVI added:
FilmCraft reads AVI 1.0 / OpenDML. Tests against ffmpeg fixtures: Motion
JPEG with PCM audio, an unsupported codec (imports and says why it has no
picture), a file that isn't an AVI (an import error), and a damaged one
(a truncated recording imports as far as it goes; a garbled packet loses
its own frame only).
2026-10-17 11:35:05 -04:00
arnav-hiwarkarandClaude Opus 5.5 ea67c46880 #1714: doc comments name the saved current times
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:40:44 +05:30
arnav-hiwarkarandClaude Opus 5.5 b9172fec2f #1714: reopen each comp at its saved current time
Layer times were already saved and read back as exact ticks; the values in
the issue are 34f, 315f and 3033f at 59.94 fps. The viewer was black because
the playhead wasn't saved: a reopened comp started at 0, before its layers.
The project file's view (`activeComp`, `openComps`) now also records each
comp's current time (`compTimes`), restored on open within the comp's frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:32:14 +05:30
arnav-hiwarkarandClaude Opus 5.5 6c3a08e780 Preview: an edit keeps frames only outside Solo changes and the shutter (#1641 review)
Turning Solo on or off hides or shows every other layer at every frame, so it
now recaches the whole comp. The kept span is widened by the comp's shutter
(phase and angle), since a frame's motion blur samples cameras, lights and
transforms up to three frames away. Comp::any_solo is shared with the
renderer so both read Solo the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:50:48 +05:30
arnav-hiwarkarandClaude Opus 5.5 17edbbffff Preview: an edit keeps the cached frames outside the layers it changes (#1641)
The RAM preview keyed every frame by one identity of the whole comp, so any
edit (a cut, a trim, a nudge) made every cached frame a miss and the whole
comp recached. When only some of a comp's layers change, frames outside their
In-Out span now move to the new identity. Settings changes, reorders,
expressions, and edits that reach other layers (parents, track mattes, effect
layer pickers) still recache everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:43:57 +05:30
DWAA1660andClaude Opus 5.5 b26a340a7e Project: comp_contains walks precomps with a visited set, not recursion (#1237)
Project::comp_contains recursed through precomp layers with no visited
set or depth limit. A damaged project with a precomp cycle that doesn't
pass the queried comp (A and B nesting each other, C unrelated) recursed
until the stack ran out, which aborts the process ("stack overflow,
aborting"): it can't be caught as a command error, so unsaved work is
lost. layer.addItem checks it before adding a precomp, and the renderer
calls it for every nested comp and its audio. A long chain used the
stack the same way, and comps shared by many paths were walked once per
path (exponential for nested diamonds).

It is now an explicit stack with a visited set: same answers (a comp
contains itself; a target reached through a cycle is found), each comp
visited once, no recursion. The renderer already skips a precomp that
contains the comp being rendered, so a cyclic project now renders
instead of crashing; nothing is repaired on load.

Tests: project comp_contains_ends_on_cycles_long_chains_and_shared_
precomps (cycle with an unrelated target, self-nesting, a target through
a cycle, 40 levels of diamonds, a 20 000-comp chain); engine
a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_
out_of_stack (file.open, layer.addItem both ways, render). On main the
engine test aborts with a stack overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:26:42 -06:00
Brandon Thomas 430c968e52 Merge pull request #933 from Themolx/replace-with-precomp
# Conflicts:
#	crates/engine/src/tests_project_items.rs
2026-10-13 04:31:42 -04:00
Brandon Thomas a985dfb36b Merge pull request #1098 from vangel76/feat-gap21-percent-guides-effect-labels
# Conflicts:
#	crates/engine/src/tests_menu_cmds.rs
#	docs/effects-parity.md
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-13 04:29:06 -04:00
Brandon Thomas 37b681ac12 Merge pull request #1104 from eliasx45/upstream/import-project 2026-10-13 04:28:59 -04:00
Brandon Thomas 15d2030d73 Merge pull request #1051 from marcoarnulfo/fix/interpret-loop-video-duration 2026-10-13 04:28:37 -04:00
Brandon Thomas c5d2a54507 Merge pull request #1013 from Themolx/unique-new-comp-names 2026-10-13 04:28:36 -04:00
Kristian MyramandClaude Haiku 5.5 863a6aff6c #1167: frames: keep the viewer frames when only plain markers change
Adding a marker replaced the comp, so every cached frame stopped matching and the preview had to
re-render before its audio could carry on. Plain markers change no pixels; protected time markers
with a duration still do (Responsive Design). Markers count in full again when a project uses
expressions, which can read them.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-13 02:20:31 +02:00
vangel76andClaude Sonnet 5.5 f11792827e Gap 21: percentage guides and effect colour labels (#1095)
Guides can store a percent of the comp so they follow comp-size changes
(View > Add Percentage Guide, Convert Guides to Percentage / Pixels); an
effect instance has an optional colour label (effect.setLabel, Effect Controls
header bar and Label menu). Both are serde-defaulted, so old projects load.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:07:36 +02:00
Elias 376f5757fc file.importProject: import an EffectCraft project into the open project
File > Import > EffectCraft Project (and .ecproj/.ecprojx given to File > Import)
merges the project into a folder named after the file, with every id shifted past
the open project's, in one undo step. Relative footage resolves next to the file;
the result lists comps, root comps, primaryComp and missing media.

offset_ids now shifts tracker Motion Targets and saturates instead of overflowing;
fix_next_id saturates too, so a damaged file can't overflow on open or import.
Menu entry translated in all ten menu catalogues.

Rebased onto current upstream main for the split of #858 (test module list in
engine lib.rs; es/pt-br/ru/fr/de/cs menu translations).

(cherry picked from commit 19c653fccc95133fb35e978a7f7f20a18c0e8c89)
(cherry picked from commit 4e751ad2)

(cherry picked from commit ceed706cc3b5f3755769738a1ddb3d965a94ab6b)
2026-10-12 19:19:21 +01:00
Marco Arnulfo a50e49df73 Interpret Footage: looped footage lasts its loops on new and re-timed layers (fixes #1045) 2026-10-12 17:13:33 +02:00
ThemolxandClaude Opus 5.5 fcf56415dc Project panel: Replace with Precomposition (used N times)
Right-clicking a footage item or solid in the Project panel offers
"Replace with Precomposition (used N times)": the item goes into a new
composition of its size, pixel aspect and frame rate, in the same folder, and
every layer in every composition that used it switches to that composition,
keeping timing, transforms, effects and masks. One undo step. Agents run it as
`file.replaceWithPrecomp`.

The Project panel's details header also shows how often the selected item is
used ("Name ▾, used 3 times"); the ▾ lists and opens the comps that use it.
`Project::item_users` and the `project.usedIn` query back both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:14:43 +02:00
ThemolxandClaude Opus 5.5 9e66e0d2ec New comps from footage get a unique name, as Duplicate does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:22:59 +02:00
Brandon Thomas b2d05582e0 Merge pull request #462 from storytold/fix/417-viewer-resolution
Viewer: each composition keeps its Resolution in the project, as in After Effects (#417)

# Conflicts:
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/viewer_tools.rs
2026-10-12 04:21:14 -04:00
Brandon Thomas 3f8af1a3b6 Merge pull request #863 from itrycodinganditsbad/fix-748
Save the active comp and open comp tabs in project files (#748)
2026-10-12 04:20:33 -04:00
Fabio LussiandClaude Opus 5.5 b0c0c61aa0 Save the active comp and open comp tabs in project files
Project files now record activeComp and openComps next to savedBy, and
file.open restores them, so a project reopens on the comp it was saved
on (desktop app, CLI and MCP all open through file.open). Older files
and ids that no longer name a comp fall back to the first comp; older
versions ignore the new keys.

Fixes #748

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:27:01 +02:00
vangel76andClaude Opus 5.5 765ba157be #788: hold opened projects and size commands to the 30,000 px limits
Problem: comp.new/comp.settings clamp sizes to 30,000 px, frame rates to
0.001-1000 fps and motion-blur samples to 2-64, but reading a project
(.ecproj and .ecprojx, templates, recovery, offload) accepted any value:
a 4-billion-px comp, solid or footage frame, a 0 or 10^18 fps rate
(zero-length frames), a negative duration or 4 billion blur samples all
reached the renderer. New Solid, Layer Settings, Import Solid/Placeholder,
Crop Comp and PSD import set sizes without the upper bound, and comp
sizes past 2^32 wrapped.

Fix: one set of limits in effectcraft_project::limits. Project::from_json
brings every item (and proxy) within them; from_json_repaired also says
what it changed, which file.open returns as `repaired` and shows in a
toast, so the project still opens. Project::add_item applies the same
limits to every new item, and the size-setting commands clamp with the
shared helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:22:59 +02:00
Claude 8d32d67f0e Color: Project Settings ▸ Working Gamma 2.2 / 2.4 with no working space, as in After Effects (#495)
After Effects' `app.project.workingGamma` (2.2 or 2.4, ignored with a working space) sets
the curve an unmanaged project uses: at 2.2 the piecewise sRGB curve, at 2.4 a pure 2.4
power curve. EffectCraft always used sRGB, so linear EXRs (Interpret As Linear Light) from
a 2.4 project came out darker in the shadows (0.18 → 0.461 instead of 0.490).

- `ProjectSettings::working_gamma` (default 2.2, serde default for old projects).
- The colour pipe's unmanaged curve is the Working Gamma's (sRGB, or Rec. 709's pure 2.4
  curve): Interpret As Linear Light encodes untagged footage with it and Blend Colors Using
  1.0 Gamma linearizes with it; it is part of the pipe's cache key. The GPU path takes the
  same conversions.
- `file.projectSettings {workingGamma: 2.2|2.4}` (other values are refused, as in AE),
  `app.project.workingGamma` in scripts and a Working gamma choice in Project Settings.

Test: render test with linear 0.051 / 0.18 / 0.5 footage (0.290 / 0.490 / 0.749 at 2.4,
0.250 / 0.461 / 0.735 at 2.2, the values measured in AE), linear blending at 2.4, a working
space ignoring the setting, and the cache key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
2026-10-10 19:54:17 +00:00
bflatasticandClaude Opus 5.5 67305fbcd1 Viewer: each composition keeps its Resolution in the project, as in After Effects (#417)
What: the Composition panel's Resolution (Auto, Full, Half, Third, Quarter, Custom) is now a
property of the composition (`Comp::resolution`, moved with its `Resolution` type from the UI
into the project crate) instead of one global viewer setting. Switching to another comp shows
that comp's resolution, switching back shows the first one's again, and saving and reopening the
project keeps them. `view.res.full|half|third|quarter|custom` and the new engine command
`view.res.auto` set it (headless too, with an optional `comp`); `ui.set {viewer: {res}}` sets
the active comp's and `ui.inspect` still reports `ui.viewer.res`; `comp.info` reports
`resolution` and `resolutionFactor`; View ▸ Resolution's check marks come from the engine.

Why: After Effects saves each composition's Resolution with the project (CompItem.resolutionFactor
is a per-comp attribute). As there, changing it marks the project modified but adds no undo step:
undo, redo and History jumps carry the current values over to the restored state. It is not part
of the comp's pixels (`Comp::same_pixels` ignores it and `Comp::pixel_form` normalises it), so
changing it keeps the comp's RAM and disk cache keys. Auto is not written (serde default and
skip_serializing_if), so projects saved before open with Auto and older versions read new files.
The magnification is not saved per comp: Adobe's documentation doesn't say it is.

Tests: project `comp_resolution_is_saved_and_old_projects_load_with_auto`,
`resolution_factors_and_scales`; engine
`each_comp_keeps_its_resolution_outside_the_undo_history_and_in_the_file` (switching comps,
no undo step, undo/redo keep it, save/open round trip, menu check marks, Custom dialog);
ui-egui `each_comp_keeps_its_viewer_resolution` (the viewer popup on comp A, comp B through
`ui.set`, back to A, unchanged frame cache identity, save/open). They don't build before the
change (no `Comp::resolution`, no `view.res.auto` engine command).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:57:00 +07:00
bflatasticandClaude Opus 5.5 d6de68b20d Import: Interpret Footage ▸ Preserve RGB keeps the file's values (#411)
Cause: float footage (OpenEXR, float TIFF) was always sRGB-encoded on
decode, and Interpret Footage could not turn that off, so an OCIO effect
on the layer converted values that were already encoded.

Fix: After Effects' Interpret Footage ▸ Color ▸ Preserve RGB
(Footage::preserve_rgb; file.interpretFootage {"preserveRgb": true}; a
checkbox in the Interpret Footage form; Remember / Apply Interpretation
carry it). The decoder keeps float values linear, and the working-space
conversion from the colour profile and Interpret As Linear Light don't
apply, so the file's values enter the composition unconverted. The media
cache key includes the flag, so both interpretations decode separately.

Tests: pool::preserve_rgb_reads_float_exr_values_unconverted,
tests_color::preserve_rgb_footage_is_not_converted (managed, linearized
and not), tests_proxy::guess_alpha_invert_and_linear_light (command,
render, Apply Interpretation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:57:12 +07:00
bflatasticandClaude Opus 5.5 a34aecd719 Import: Interpret Footage has Start Timecode, as in After Effects (#297)
After Effects' Interpret Footage has Start Timecode: Use Source File
Timecode, or Override Start, which changes the timecode the footage
shows and leaves its frames alone. The Start Frame field that trimmed or
padded a sequence's head isn't in After Effects, so it is replaced.

Footage.start_timecode (Override Start, in frames at the footage's
rate; None = Use Source, 0 as file timecode isn't read) relabels source
time: the Footage panel's current time and footage.info's `timecode`
count from it. file.interpretFootage takes `overrideStart` and
`startTimecode` (timecode, read at the footage's rate after any frame
rate change, or a frame number, 0 to 1e9). It applies to every footage
with time, not only sequences, and Reload keeps it. A sequence's frames
always run from its first file's number to its last.

Test: start_timecode_only_relabels_source_time (project),
interpret_footage_start_timecode_relabels_source_time (engine: the
reply, the Footage panel's timecode, frame numbers, Use Source, the
footage's rate, bad values, undo), Reload keeps it, and the Interpret
Footage fields in ui_drag_drop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:50:39 +07:00
Brandon Thomas e12bf5ca7d Merge pull request #289 from storytold/fix/227-shape-effect-points
Effects: effect points on shape layers start at the layer centre (#227)

# Conflicts:
#	crates/ui-egui/src/panels/puppet_tool.rs
#	crates/ui-egui/src/panels/viewer.rs
2026-10-09 09:32:13 -04:00