Skip to content

perf: faster effects, expressions, text, caches, movie reading, project open, export and UI playback - #856

Open
eliasx45 wants to merge 47 commits into
storytold:mainfrom
eliasx45:upstream/combined-perf
Open

eliasx45 wants to merge 47 commits into
storytold:mainfrom
eliasx45:upstream/combined-perf

Conversation

@eliasx45

@eliasx45 eliasx45 commented Oct 11, 2026 •

Copy link
Copy Markdown

What

Effects

  • Four CPU kernels, bit-identical output: Plane::transpose is cache-blocked in 32×32 tiles (twice
    per vertical pass of box_plane / gauss_plane); Median updates its histogram inline through a
    generic column visitor; Camera Lens Blur sums iris span counts once per iris, not per pixel; Cell
    Pattern computes each cell's feature point once per frame (about 7× faster at High Quality).

3D

  • Depth of field: the iris kernels gather only the two blur levels around each pixel's radius from
    one set of row prefix sums, instead of keeping up to eight full-image blurs (about 4× faster).
  • Both 3D compositors skip preparing shadow casters when shadows are off (Draft without Realtime
    Shadows).

Layer cache

  • Under budget pressure, quantised 8 and 16 bpc entries are packed as u8 / u16 (PackedBuf)
    instead of evicted; packing checks every value round-trips bit for bit and declines otherwise. The
    cache holds 2–4× as many 8/16 bpc layers at the same budget.

  • More caches follow the Memory & CPU budget: the GPU upload cache drops freed layers' textures on
    every upload and evicts least recently used when over budget, with the texture pool sized from the
    layer-cache budget; Roto Brush segmentations get half the layer-cache budget (at most 768 MB); the
    disk-cache write queue is bounded by bytes.

Media

  • Movies open through a file reader: the sample index is read at open and samples on demand.
    Standalone audio stays on the existing FilmCraft path. The direct Symphonia dependency and its
    standalone-audio probing shortcut have been removed to follow ADR 0001.

Expressions

  • A run collects every missing host value at once and prefetches what the previous evaluation read;
    keyframe lists are read lazily; sourceRectAtTime on shape layers is geometric; a layer's own path
    is memoised.
  • A per-thread memo shares results between evaluations; it returns a stored result only when every
    recorded host answer is bit-identical, never stores evaluations that read randomness or the clock (including aliases and property descriptors), and is bounded
    by bytes. A 100-layer expression rig drops from 23.4 to 14.7 ms per frame.

Text

  • Glyph outlines are read under a RwLock and transformed straight from the shared Arc;
    layout_doc caches its finished TextLayout; the layout and shaping caches are byte-bounded LRUs
    (64 / 32 MiB); a .ttc file is read once for all of its faces; the UI shares the engine's font bytes.

Project open

  • Property-tree nodes stream during loading (a hand-written Deserialize reads the "node" tag
    first instead of buffering every subtree); migrate::is_current avoids deep-cloning every effect
    to compare it with its upgrade. A 20.9 MB layer array parses in 72 ms instead of 226 ms.

Export

  • Export follows the Memory & CPU budget: JobOptions::memory_budget (default 1 GiB) sizes the
    layer cache and bounds the frames in flight (one per core, 2–16, fewer when they'd exceed it).
  • Movie and WebM exports render the next batch while the current one encodes and muxes (at most two
    batches held); serial on wasm and rayon workers.
  • A Render Queue item with several output modules renders each frame once: modules that render the
    same frames share one render, each with its own encoder and muxer thread, and files stay
    byte-identical; others reuse the previous module's layer cache when the frames match.

UI

  • Playback: viewers upload shared frames without copying pixels, passive viewers share the comp
    instead of cloning it every frame, a playing preview repaints when its next frame is due (not at
    the display rate), and the channel / exposure transform runs in parallel.
  • Panels: thumbnail textures are bounded by an LRU; the Timeline cache bar remembers its frame lists
    per cache generation instead of scanning the caches under locks every UI frame; Lumetri Scopes
    share their cached scope; the Footage panel decodes on a worker thread at panel size; the native
    menu catches up after a drag and updates at most 4 times a second.

Why

These were the hottest paths in effect renders, expression rigs, text layers, project open and
exports and the UI thread: rendering and encoding alternated, opening a long movie read the whole
file, playback copied every frame and repainted at up to 120 Hz, multi-module items rendered every frame N times, and a
slider read by N layers ran N times per frame.

Earlier validation (before this review update)

  • rustfmt --edition 2024 --check on every changed .rs file: clean.
  • cargo clippy --all-targets -- -D warnings for effects, engine, export, expr, gpu, host, media,
    project, render, text, ui-egui and effectcraft: clean.
  • cargo check --target wasm32-unknown-unknown for effects, engine, export, expr, gpu, media,
    project, render and text: clean.
  • cargo test for the same twelve packages: 2,055 passed (the perf changes keep output
    bit-identical where their tests say so: kernels, packed cache, export files).
  • Known failures, identical on main on this runner: theme::script_font_tests::arabic_words_are_joined_and_read_right_to_left,
    and in effectcraft-gpu's lib tests on llvmpipe two CPU/GPU mismatches (CC HexTile, CC Threshold RGB)
    and a segfault later in the suite.
  • Not run: the full cargo xtask ci. Tests ran on a 4-core Linux runner without a GPU (wgpu on
    llvmpipe).

Cherry-picked with -x trailers from topic branches already adapted to current main.

Review update

Merged current upstream main, preserved both GPU cache budgets, removed the direct codec dependency,
and fixed clock reads reached through property descriptors so they cannot be memoized.

Current-tree validation: formatting and strict full-workspace all-target release Clippy passed. The descriptor-bypass fix passed exact-wrapper Node and Boa probes. Full runtime CI was intentionally stopped during compilation; the full suite has not passed on this tree. Runtime tests use RUST_TEST_THREADS=1; the shared process-global cache collision found in the upstream baseline is fixed separately in #855. Earlier benchmark numbers above have not been remeasured.
Maintainer decisions remain on the bundle's scope and its overlap with open #898. #688 is already merged upstream; this integration preserves its upload-cache budget alongside the layer-cache budget.

claude and others added 30 commits October 11, 2026 11:49
Used twice per box_plane/gauss_plane vertical pass; bit-identical to the
column-by-column version (tested against it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDewEVE9bAYgXzvLq94jnb

(cherry picked from commit 8c70c77)
(cherry picked from commit 49971b6)
Replaces the per-sample &mut dyn FnMut; bit-identical to the old code
(kept as a test oracle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDewEVE9bAYgXzvLq94jnb

(cherry picked from commit 2b3d2b6)
(cherry picked from commit 95ea456)
Pixel counts were re-summed for every output pixel; output bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDewEVE9bAYgXzvLq94jnb

(cherry picked from commit d84d1b1)
(cherry picked from commit 3383520)
Bit-identical (oracle test against the per-sample computation); HQ ~7x faster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013MFSUedLFmbuKi4EawpizQ

(cherry picked from commit 89f485e)
(cherry picked from commit 32281ff)
(cherry picked from commit b7bb2dba1a62405f5712432acf05a6726b713820)
(cherry picked from commit 3e3fe2d)
…nder budget pressure instead of evicting

(cherry picked from commit 3e72db6)
(cherry picked from commit 28722b5)
(cherry picked from commit eb10f4ce5518fbed2e8530e2e22340da3b3c93d4)
(cherry picked from commit c32fcb5)
… pixel

progressive_blur blurred the whole image at up to eight radii and kept them all;
iris kernels now gather just the two levels around each pixel's radius from one
set of row prefix sums (Fast Rectangle's O(1) box levels are unchanged).
Bit-exact against the old code (test oracle); ~4x faster at 640x480, 0..24 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2Vw4ifTRhxWcLfvzW3G9b

(cherry picked from commit f157946)
(cherry picked from commit e109c7a)
… shadows

Both compositors drop them unused when RenderOpts::shadows() is false (draft
without Realtime Shadows); output unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2Vw4ifTRhxWcLfvzW3G9b

(cherry picked from commit dd4e94f)
(cherry picked from commit c03b345)
…tion's reads, lazy keyframe lists, geometric shape sourceRectAtTime, memoised own path

(cherry picked from commit 2b95185)
(cherry picked from commit fead213)
… answer the last run read

A per-thread memo keyed by script, comp, layer, property, comp time and the
__begin inputs. A hit answers the recorded requests at the evaluation's own
depth and returns the stored result only when every answer is bit-identical,
so edits can never serve a stale result; a slider read by N layers runs once
per frame. Scripts using Math.random/Date are not stored. Tests compare
against fresh runs on a thread with the memo off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VzreW5cLMczbUzegSKxcUv

(cherry picked from commit ff5e217)
(cherry picked from commit 2f03926)
…ares memo off/on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VzreW5cLMczbUzegSKxcUv

(cherry picked from commit de244f3)
(cherry picked from commit 2d3f690)
…upstream)

(cherry picked from commit e9b042e9fe6f6f52b9ec92c9a084f169a75148f0)
(cherry picked from commit 5e1cc23)
Glyph outlines are read under a RwLock and transformed straight from the
shared Arc (no clone). layout_doc caches its finished TextLayout keyed by
the shaped layout, styles and box, and both that cache and the shaping
cache are byte-bounded LRUs (64 / 32 MiB) instead of 512 entries. Output
is identical to the previous path (oracle test over varied documents).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TZCme2FE1XWV7FWbjyAxaW

(cherry picked from commit 65bfe27)
(cherry picked from commit 4dbb636)
…nd the UI shares the text engine's bytes instead of copying fallback fonts

(cherry picked from commit 23dffa9)
(cherry picked from commit 42924af)
(cherry picked from commit f61830766970fa32780dce700b792e006e8dfd7a)
(cherry picked from commit d9befa8)
…s branch)

(cherry picked from commit 1b4f841f1408ba2db9dd438f28ad7d0ca58b3e98)
(cherry picked from commit bfb97cd)
`Node` was an internally tagged serde enum, so serde buffered every node subtree into
its private `Content` tree before converting it. A hand-written `Deserialize` reads the
`"node"` tag when it comes first (serialization always writes it first) and streams the
rest of the map into `Property`/`PropGroup`; a tag found later falls back to buffering.
Serialization is unchanged (still derived). Parsing a 20.9 MB synthetic layer array
(200 layers x 5 animated props x 200 keys) drops from 226 ms to 72 ms in release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123prcPx5zRkZvD52XuVKJE

(cherry picked from commit cd480a0)
(cherry picked from commit 5614422)
migrate::is_current is a read-only check mirroring upgrade_instance; current
instances return early instead of being deep-cloned for the comparison.
A test checks it never skips a needed upgrade over per-parameter variants of
every registered effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kqn7byVXrwKLfhCti44RYL

(cherry picked from commit ca89dc7)
(cherry picked from commit ff825b7)
…torytold#514), so opening a project upgrades old Apply Color LUT / OCIO instances

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAHHERF9trzfV2uSdYVBc6

(cherry picked from commit 0b083de)
(cherry picked from commit 2536391)
…it, frames in flight bounded by frame bytes

(cherry picked from commit 936c521)
(cherry picked from commit 9df172b)
…e, WebM VP9/AV1)

Cx::batches hands batches in order from a scoped renderer thread through a
rendezvous channel (at most the batch being encoded + the one rendered next).
Serial in passes, on wasm and on rayon workers. Output bytes unchanged.

Rebased for upstream: keeps upstream's audio start-tick fix and the budget-sized batch_size(job, comp); tests/pipelined.rs is its own test binary.

(cherry picked from commit 6159698)

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

(cherry picked from commit fa3f7993be048105866a8bfb8710875eeffffeb0)
(cherry picked from commit bca439c)
…vious module's layer cache

A Render Queue item with N output modules exported each module as a fresh job:
a project clone and an empty layer cache per module, so static and
transform-only layers rendered N times. A finished export now keeps its
overridden project and layer cache (one slot, dropped after 3 s or when the
next job takes it); the next export takes it only when it renders the same
frames: equal project, same comp, Render Settings equal but for the time span,
same Switches Affect Nested Comps, memory budget, footage source, expression
host and accelerator. Layer buffers are content-keyed, so files are
byte-identical to each module exported alone (new output_modules test:
H.264, PNG sequence, ProRes). Failed or cancelled exports keep nothing; the
web keeps nothing (no timer to drop it). Progress, cancellation and error
semantics of the queue are unchanged and now documented at the module loop.

tests/output_modules.rs is its own test binary upstream (no tests/export/main.rs).

(cherry picked from commit f3f683c)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ldtkj4yRfYi6j6CwRrde11

(cherry picked from commit 508f9b983694724031519126685a4f8e0c83a12a)
(cherry picked from commit 4733a7c)
export_modules / export_modules_async export one Render Queue item's
output modules: modules rendering the same frames (same project, comp,
Render Settings incl. time span, session objects, memory budget, Crop
and Resize) export at once from one render of each frame, each with its
own encoder and muxer on a thread of its own; the rest export alone.
Batches keep the per-module partition (batch_size) so files stay
byte-identical, the next batch renders while modules encode, and a
batch goes to one module at a time. Results, progress, stop and failure
behave as modules exported one after another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kdPxhtproheZmiPj5p3ML

(cherry picked from commit b697b25)
(cherry picked from commit ccb2002)
Exporter::export_modules_async takes every output module of a queue
item; the default exports them one after another as before, so an
exporter can render the frames they share once. The item's progress
shows the module furthest behind; result, stop and failure handling
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011kdPxhtproheZmiPj5p3ML

(cherry picked from commit 6c73d53)
(cherry picked from commit 335c8de)
…dules

FileExporter overrides Exporter::export_modules_async with
effectcraft_export::export_modules_async (same job construction, progress,
cancellation and error mapping as export_async), so the app fans one render
of each frame out to every output module that shares it. Tests: byte
identity with each module alone and one footage request per frame for 2 and
3 modules, a stop mid-way, a failing module, and an ignored timing test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fwx5KNPtB2EbV1TCwq4puC

(cherry picked from commit 49ca5eb)
(cherry picked from commit f3e743c)
…and probe audio/movies from headers, not whole-file reads and decodes

(cherry picked from commit 2deafbd)
(cherry picked from commit f679c35)
(cherry picked from commit c534678c3034f68a1bd49574f9f36f0d480b8010)
(cherry picked from commit 4a05bd3)
…pload; upload/pool budgets follow the Memory & CPU layer budget

(cherry picked from commit 3914415)
(cherry picked from commit 9cae1d2)
…he write queue bounded by bytes

(cherry picked from commit 45c75c3)
(cherry picked from commit e07abb8)
0Elias71 and others added 13 commits October 11, 2026 12:30
…e viewers share the comp instead of cloning it every frame

(cherry picked from commit c0b5490)
(cherry picked from commit 1bdb90a)
… display refresh rate

(cherry picked from commit f171eb5)
(cherry picked from commit f5efa7b)
(cherry picked from commit 8b84e20)
(cherry picked from commit b32ab5e)
(cherry picked from commit 026bce565b8a841ff0587125bebb9425babbb4da)
(cherry picked from commit 0134eac)
…h an LRU

(cherry picked from commit fe11be6)
(cherry picked from commit 65e2c80)
(cherry picked from commit 7a08a4f5fee6e4dbe885c51325c9e660476ae464)
(cherry picked from commit 1133015)
… (A9)

cached_frames and disk_frames scanned the RAM cache and hashed a disk key per comp
frame (with mutex locks) on every UI frame. RAM and disk caches now count changes,
and the lists are kept per series until the frames, project or caches change.

(cherry picked from commit 8c0b997)
(cherry picked from commit c8bf68c)
…while playing (A8)

(cherry picked from commit 2119dd7)
(cherry picked from commit a25de99)
…r E)

(cherry picked from commit 2f7e6eb)
(cherry picked from commit bf01e78)
… to the panel size, keyed on item + frame + interpretation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gh8SBi8MuyzgBSJ2f5n7W

(cherry picked from commit f3e5438)
(cherry picked from commit 6b053c6)
…orrow)

(cherry picked from commit 9ff7208ba18657fa813aa7bbcfb3f8de5f2e29e8)
(cherry picked from commit 3848d2e)
…rame of it (Tier E)

(cherry picked from commit d1c5fca)
(cherry picked from commit 9069318)
…e spec changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Gh8SBi8MuyzgBSJ2f5n7W

(cherry picked from commit 7c4e305)
(cherry picked from commit d4703e6)
eliasx45 pushed a commit to eliasx45/effectcraft that referenced this pull request Oct 11, 2026
# Conflicts:
#	crates/export/src/encode.rs
#	crates/expr/src/lib.rs
#	crates/expr/src/runtime.rs
#	crates/media/src/layered.rs
#	crates/media/src/pool.rs
#	crates/media/src/probe.rs
#	crates/media/tests/footage.rs
#	crates/ui-egui/src/frames.rs
#	crates/ui-egui/src/panels/viewer.rs
@echelon

echelon commented Oct 13, 2026

Copy link
Copy Markdown
Member

Thank you for this, @eliasx45!

We're holding this for the maintainers: it's a 45-commit grab-bag, so they'll decide whether to take it whole or ask for a split. Points to settle either way: it adds symphonia as a direct dependency of crates/media (ADR 0001 says codecs come through FilmCraft); it conflicts with #898 and #688; and the expression memo skips scripts by text match (Math.random, Date), so Math["random"] gets around it.

If you are not on our Discord yet, please join us: https://discord.gg/artcraft

…owever it reaches them

Review on storytold#856: the memo skipped scripts only by text (Math.random, Date), so Math["random"]()
or an alias got around it. Math.random and Date (construct, call, Date.now, and through
Date.prototype.constructor) are now wrapped at start-up so a call marks the evaluation impure,
and impure runs aren't remembered; the text check stays as a cheap first filter. Test:
impure_calls_are_caught_however_they_are_spelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014d65YdNxQmjRkbXRtP2NwH
@eliasx45

Copy link
Copy Markdown
Author

@echelon Thanks. Fixed: the expression memo now catches Math.random/Date however they're reached (Math["random"], aliases, Date.prototype.constructor). Impure runs are never cached. Open question: is the symphonia dependency OK, or should it go?

@eliasx45

Copy link
Copy Markdown
Author

@echelon Updated in 98db5fe: removed the direct Symphonia dependency (standalone audio stays through FilmCraft), closed the Date.now property-descriptor memo bypass, and integrated current main while preserving #688’s upload-cache budget alongside the layer-cache budget. Also corrected two merge mismatches in expression fallback handling and the property-parser reference tests.

Formatting and strict full-workspace all-target Clippy pass. Exact clock-wrapper probes pass in Node and Boa. Full runtime CI was stopped during compilation; no full-suite or fresh performance claim. The PR description distinguishes earlier measurements from current checks. The bundle’s scope and overlap with open #898 remain maintainer decisions.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants