Files
effectcraft/docs/web.md
T
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

516 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EffectCraft on the web
`apps/effectcraft-web` runs the same engine and egui UI as the desktop app in the browser: the
workspace compiled to `wasm32-unknown-unknown`, started by eframe's web runner on WebGPU (WebGL2
fallback). It is a static site: one `.wasm`, its `wasm-bindgen` JavaScript glue, `index.html`, a
few small scripts (worker, audio worklet, service worker), a web manifest and icons. Nothing is
uploaded anywhere: projects, media, settings and renders stay in the browser (its private storage)
and on the user's machine.
## Build and run
| Tool | Version | Install |
|---|---|---|
| Rust target | `wasm32-unknown-unknown` | `rustup target add wasm32-unknown-unknown` |
| wasm-bindgen CLI | exactly the `wasm-bindgen` crate version (0.2.129) | `cargo install wasm-bindgen-cli --version 0.2.129 --locked` |
| wasm-opt (optional) | any recent binaryen | `brew install binaryen`; used by `cargo xtask web` when found |
```sh
cargo xtask web # release build → <target>/web/dist
cargo xtask web --dev # unoptimised build (faster to compile, slow to run)
cargo xtask web --serve 8765 # build, then serve dist on http://127.0.0.1:8765/
```
`<target>` is `$CARGO_TARGET_DIR` or `target`. `dist` holds `index.html`, `effectcraft_web.js` +
`effectcraft_web_bg.wasm` (+ `snippets/`, the browser glue `js/host.js`), `worker.js`,
`audio-worklet.js`, `sw.js` (its precache list and version filled in by the build),
`manifest.webmanifest`, `favicon.svg` and `icon-256.png` / `icon-512.png` (copied from
`assets/app-icon`). `--serve` is a tiny localhost-only static server that sends
`Cross-Origin-Opener-Policy` / `Cross-Origin-Embedder-Policy`, so the page is cross-origin
isolated like a production deployment should be. Any static server works
(`python3 -m http.server -d target/web/dist 8765`); it must serve `.wasm` as `application/wasm`.
Service workers need a secure context: `https://`, or `http://localhost` / `127.0.0.1`.
The release `.wasm` is about 58 MB before `wasm-opt` (v0.3.1; code: the effects, codecs, the
expression engine and the UI; data, mostly the bundled fonts; function names for readable
panics). Served compressed it is about 17 MB with gzip or 10 MB with Brotli, and the service
worker caches it after the first visit. Its name doesn't change between releases, so hosts
revalidate it rather than caching it for long (`packaging/web/README.md`).
On other targets the web crate holds only its portable storage model (`store.rs`, unit-tested by
`cargo test --workspace`); `cargo xtask wasm` (part of `cargo xtask ci`) checks the whole crate,
with every L0–L4 crate and `effectcraft-ui-egui`, for `wasm32-unknown-unknown`.
URL flags: `?empty` (a blank project; the session is not restored or recorded), `?demo` (the demo
project instead of the last session), `?home` (show the start screen), `?storage=indexeddb` /
`?storage=memory` (force a storage backend), `?noworkers` (render, analyse and draw viewer
frames on the page's thread), `?frameworkers=N` (frame workers, default 2; 0 = viewer frames on
the page's thread), `?nogpuworkers` (frame and job workers render on their CPU, without a WebGPU
device of their own), `?nosw` (don't register the service worker).
## How the desktop pieces map to the browser
| Desktop | Web |
|---|---|
| frame render thread pool (`ui-egui/src/frames.rs`) | frame workers (below), each with its own WebGPU device: `Frames::pump` hands queued frames to them, most important first (the viewer's frame, then prefetch); frames without effects that the page's GPU compositor can keep on the GPU render on the page. Without workers, queued frames render on the UI thread after each egui frame within 40 ms (24 ms while playing) |
| Render Queue on a background thread | a Web Worker running a second engine instance with its own WebGPU device (below); progress streams back, the page never waits |
| tracker / mask tracker / Warp Stabilizer / 3D Camera Tracker / Roto Brush Freeze threads, Content-Aware Fill tasks | the same workers; the analysed property group (or the fill layer) comes back as one undo step |
| `rayon` parallel loops (raster, effects, export batches) | rayon's global pool falls back to the calling thread when threads are unavailable: same code, serial |
| `std::time::Instant` / `SystemTime` (panic on wasm32) | `web-time` (re-exports `std::time` on native) |
| `std::fs` project reads/writes (`FsServices`) | `files::WebServices`: the virtual file table, persisted (below); saving also downloads the `.ecproj` |
| config directory: settings, shortcut presets, recent projects, the recovery sentinel | `store::WebConfig` in browser storage |
| auto-save folder | `/EffectCraft Auto-Save/` in browser storage (`ConfigStore::files`) |
| media reads (`MediaPool`, `probe`) | `files::WebImporter`: bytes from the file table, handed to `MediaPool::add_bytes` / `probe_bytes` |
| Memory & CPU cache budgets, scaled to the system's RAM | the page's memory, at most 4 GiB (wasm32), which the caches share with the decoders' own decoded frames (FilmCraft keeps up to 384 MB per open movie): the Layer Cache stays within 512 MB and the Footage Frame Cache within 256 MB (`Prefs::cache_budgets`) |
| export writes (`effectcraft-export`) | the job's `sink` (`effectcraft_host::FileExporter { sink }`): files land in the table and download after the render; several files (an image sequence) download as one stored `.zip` |
| rfd file dialogs | `<input type=file>` for File ▸ Open / Import; drop files anywhere on the page (`.ecproj` opens, everything else imports); "Save As" / "Output To" pick a download name |
| Media Browser on the file system | browser storage and folders opened with the File System Access API (below) |
| system fonts | not scanned; the bundled fonts (Inter, Noto Serif, JetBrains Mono) are always there |
| TCP control channel / MCP | `window.effectcraft` (below) |
| cpal audio output | Web Audio (below) |
| GPU compositor on the desktop's wgpu device | the same compositor on eframe's WebGPU device, and GPU effects on each frame worker's own WebGPU device (below) |
| disk cache folder (Settings ▸ Disk ▸ Disk Cache) | viewer frames and slow layer buffers in the Origin Private File System, written by the frame workers (below) |
| (the file system's own tools) | the storage manager: Settings ▸ Disk ▸ Browser Storage and `storage.*` (below) |
### Persistence
Everything that lives in files on the desktop lives in the browser's **Origin Private File
System** (OPFS); where OPFS can't be written from the page (older Safari), **IndexedDB**; with
neither (some private windows), memory only. `effectcraft.info().storage.backend` says which.
The page asks for persistent storage (`navigator.storage.persist()`), which browsers grant to
installed apps, so the store isn't evicted under storage pressure.
The engine's storage traits are synchronous and the browser's are not, so the app works on an
in-memory mirror (`store.rs`): every stored entry is read before the app starts, reads and writes
hit the mirror, and changes flush in the background (one write per changed key, in order; OPFS
writes are atomic: a swap file replaced on close). Keys:
| Key | What |
|---|---|
| `config/prefs.json`, `config/shortcuts.json` | Settings (with File ▸ Open Recent's list) and keyboard shortcut presets |
| `config/session.lock` | the crash-recovery sentinel |
| `config/session.ecproj`, `config/session.json` | the session snapshot: the open project and editor state (active comp, time, selection), written at most once a second while they change |
| `files/<path>` | the file table: imported media, projects saved with File ▸ Save / Save As or `saveToBrowser`, auto-saves |
On the next visit the app reopens the snapshot (unsaved changes stay unsaved: the project is
modified until saved, `Session::mark_unsaved`, so File ▸ New, Open, Close Project and Revert still
ask to save it first), registers every stored media file with the media pool so its footage decodes, and File ▸
Open Recent reopens saved projects from storage. Without a snapshot (first visit) it opens the
demo project. Render outputs are not stored: they download.
### Background jobs: Web Workers
wasm threads need shared memory, which needs a nightly `build-std` toolchain, so this build is
single-threaded per instance. Long jobs run in **Web Workers** instead, each with its own instance
of the same module (the page compiles it once and posts the `WebAssembly.Module`, so nothing is
compiled twice) and its own memory (`crates/engine/src/offload.rs`, `src/worker.rs`):
1. The session's `Offload` serializes a `WorkerRequest`: the project JSON, the footage paths it
reads, and the job (`render` with the resolved queue items and output paths; `warp`, `camera`,
`track`, `maskTrack`, `rotoFreeze`, `rotoPropagate` with the target and analysis parameters;
`contentFill` with its plan).
2. `js/host.js` takes an idle worker from its pool (or starts one), sends it the footage bytes it
doesn't have yet, then the request.
3. The worker runs the job on a plain engine session and posts `WorkerReply`s: render progress
(at most every 100 ms), item started/finished, rendered files (transferred), analysis
progress, the analysed property group, `failed`, `done`; then `{type: "stats"}` with the
job's GPU passes and readbacks. With a WebGPU device of its own the job's frames render on
it in passes (below, **GPU in job workers**).
4. The page applies them every frame: `Session::poll_render` updates the queue items and the
progress bar, `Session::poll_offload` writes an analysis result as one undo step (the effect /
tracker / mask group is replaced; other edits made meanwhile are kept) and the downloads start.
Stopping a job terminates its worker (a worker can't be interrupted mid-frame): the item being
rendered becomes "User Stopped", an analysis writes nothing. Every job shows in Window ▸ Progress
(`jobs.list`) with its progress and a cancel button (`jobs.cancel`).
**Roto Brush propagation** runs in a job worker too (`WorkerJob::RotoPropagate`): the worker
streams the segmentations it computes back at most every 250 ms (`WorkerReply::Segs`: each
`FrameSeg` in a compact binary form, run-length coded planes, base64), the page puts them into its
segmentation cache and relays them to the frame workers. Stopping it keeps the frames propagated
so far.
**`wait: true`** (the default of `renderQueue.render` for agents, and an option of the analysis
and Roto Brush commands) no longer blocks the page: over `window.effectcraft` (the control
channel) such a command runs with `wait: false`, and the promise resolves when the job it started
ends, with what the blocking command returns (final render item statuses, the analysis status)
or rejects with the job's error (`ui-egui/src/control.rs`, `JobWaiter`). Engine commands executed
directly on the session (scripts) still block.
### Viewer frames: frame workers
Viewer frames (the frame on screen, RAM preview and prefetch) render in long-lived **frame
workers** (`src/frames.rs`, `crates/engine/src/remote.rs`), two by default. Each holds a replica
of the project:
1. Before a render, the page sends a worker the footage files it lacks, then a sync: the whole
project the first time, afterwards a **diff** of the serialized project from the revision the
worker holds (`remote::diff`: object members added / changed / removed, arrays element-wise
when their length is unchanged; a slider drag sends a few hundred bytes, not the project).
2. Then the render request (`FrameMsg::Render`: revision, comp, time, render options). The worker
renders on its CPU renderer and posts the frame back as premultiplied RGBA8 in a transferred
buffer (`FrameReply::Frame`); the page puts it in the frame cache.
3. One frame per worker at a time, so the page always picks what renders next (the viewer's frame
before prefetch); a request for a revision the worker does not hold is refused, a worker that
lost its replica asks for the whole project again (`FrameReply::Resync`).
Frames without effects that the WebGPU compositor can finish alone still render on the page
(they stay on the GPU, no readback); everything else (frames with effects, 3D runs, the Software
Only renderer, WebGL2) goes to the workers. If no worker is usable (one that failed to start, or
one the browser refused to create, as in a sandboxed iframe), frames render on the page's thread
as before. `effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered,
failed, lastMs, syncs, syncBytes, gpu, gpuFrames, lastPasses, diskHits}` (`gpu`: each worker's
adapter, `{cpu: why}` without one, `null` until it reported).
**GPU in job workers (M13.30).** Each job worker opens its own WebGPU device too
(`workerJobInit`; `?nogpuworkers`: their CPU) and makes it the session's accelerator. A job
can't block on a readback either, so the job loops are futures rather than blocking loops:
`effectcraft_engine::offload::run_request_async` runs the request, and every frame it renders
goes through `effectcraft_render::passes` (`in_passes`, `comp_frame`): render a pass, and while
it missed, await the device (`Accelerator::settle`, the browser's event loop delivers the
readbacks) and render again, as `FrameServer::resume` does for viewer frames (the desktop drives
the same futures with `passes::block_on`; they never wait there).
- **Render Queue**: `effectcraft_export::export_async` renders each frame in passes (frames
with deferred readbacks render one by one instead of in parallel batches) and encodes as
before (`Exporter::export_async`). As on the desktop, a job renders every frame on the
project's renderer (the worker's GPU with Mercury GPU Acceleration), not on whichever
compositor measured faster, so runs don't differ by timing.
- **Analyses** (Warp Stabilizer, 3D Camera Tracker, Track Motion, mask and face tracking, Roto
Brush freeze and propagation): their work functions are `async`; started with `wait` inside
a job they are queued and awaited (`offload::run_or_queue`), and each input frame renders in
passes with the GPU backend (`offload::job_accel`). Roto Brush's segmentation reads frames
through a callback, so its frame source prefetches the frame and its neighbours in passes
before each step. On the desktop analyses render their input on the CPU, as before.
- **Particles and Advanced 3D** read back under keys like effect chains (`crate::deferred`):
a particle simulation by its request (system, steps, births), a rasterised scene by
everything it uploads, a whole prepared run by its scenes and output; the first pass starts
the readback and gets a placeholder (no particles, an empty target), later passes read the
bytes. A run with depth of field resolves on the CPU (its blur needs a readback mid-run),
each of its scenes still rasterised on the GPU. So frames with particles or Advanced 3D
render on the device in frame workers and job workers alike.
`effectcraft.info().workers` reports `{running, idle, gpu: {adapter} | {cpu: why}, jobs:
[{kind, gpu, passes, readbacks, ms}]}` (the last jobs).
**Warp Stabilizer plans off the page (M13.32).** Solving a stabilization plan (Subspace Warp:
an eigen-decomposition per frame, then a mesh fit) takes seconds for a long clip, and
`warp.status` used to solve it on the page the first time it saw the analysis (a ≈ 5 s
stall). Now the analysis job solves the plan for the analysed settings and sends its summary
(`WorkerReply::WarpPlan`: per-frame warps, auto-scale, crop, valid fraction) with the result.
When the settings change later, `warp.status` reports `stabilizing: true` and starts a
`WorkerJob::WarpPlan` job (`jobs.list` id `warpPlan`) instead of solving it; the next status
after it ends has the plan. Frames with the effect render in the frame workers, which solve
their own plans. The desktop solves the plan in place, as before.
**Content-Aware Fill in a job worker (M13.30).** `contentFill.generate` (the panel's Generate
Fill Layer) sends its plan (`content_fill::FillPlan`: layer, frames, method, folder) to a job
worker (`WorkerJob::ContentFill`, `jobs.list` id `contentFill`): the worker renders the
layer's frames in passes on its device, fills them and writes the PNG sequence through its
services, which send each file to the page's browser storage (`{type: "store"}` messages, under
`/Fill/<layer>_Fill_<n>/`); then the plan comes back (`WorkerReply::Fill`) and the page adds
the fill layer as one undo step. (Before, the fill ran on the page's thread and could not
write its folder.)
**Threading design: one engine instance per worker (decided, M13.24).** Shared-memory threads
inside one instance (SharedArrayBuffer + atomics, a `wasm-bindgen-rayon`-style pool) need the
standard library rebuilt with atomics (`-Z build-std`, nightly Rust) and every crate's statics
made thread-safe for the browser's rules (no blocking on the page's thread). The repository
builds on stable Rust, so concurrency comes from workers that each run their own engine instance
with their own memory: job workers (renders, analyses), frame workers (viewer frames, each with
its own GPU device) and the page. What crosses between them is explicit: project diffs, footage
bytes, frames, Roto Brush segmentations, cache keys. The cost is memory per worker (a project
replica, the footage it reads, a layer cache) and serial loops inside one frame (rayon's pool
falls back to the calling thread). The page is still cross-origin isolated (`--serve` and the
service worker send COOP / COEP), so a threaded build (`build-std` with `+atomics,+bulk-memory`,
`wasm-bindgen-rayon`) could be added later without changing the deployment; it would parallelise
the loops inside a frame, not replace the workers.
### Media Browser
Window ▸ Media Browser browses a virtual tree (`src/browse.rs`):
- **Browser Storage**: every file in browser storage (imported media, saved projects, files added
with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is
the persistent recent list where folders can't be opened. A picked file that can't be read is
named in the status bar with the browser's reason, and the others are still added.
- **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a
folder from the user's disk; only the listing is read, and a file's bytes are read when it is
imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after
a reload the folders come back if the browser still grants access, else **Reconnect <name>**
asks again.
`mediaBrowser.list` reports the places and these actions; `mediaBrowser.action {action}` runs one.
### Startup project
By default the page opens the last visit's project, and the demo project on a first visit. A
`startup` key in `effectcraft-config.json` (next to `index.html`) changes that:
```json
{"startup":"empty"}
```
| Value | On load |
|---|---|
| `"last"` (or no key) | the last visit's project; the demo project on a first visit |
| `"empty"` | the last visit's project; a blank project on a first visit, never the demo |
| `"demo"` | the demo project, whatever the last visit left; the session is not saved, so the last visit's project stays as it was |
The `?empty` and `?demo` URL flags still win. A missing, invalid or non-JSON file, or an unknown
value, is the default. The file is fetched once per page load; serve it with
`Cache-Control: no-cache` (the service worker never caches it).
### Audio
Audio preview goes through **Web Audio**: an AudioWorklet (`audio-worklet.js`; a
ScriptProcessorNode where worklets are unavailable) plays interleaved stereo blocks. The mixdown
is the desktop's (`AudioPlayback`, `mix_comp`): without a feeder thread, `AudioPlayback::pump`
mixes ahead on every UI frame (≈ 350 ms queued) and the output device takes what keeps it
≈ 150 ms ahead of the playhead. A/V sync follows `AudioContext.currentTime`: the playback clock
is the frames played by the context clock minus its output latency, and the viewer shows the
frame under the audible sample, as on the desktop. The Audio panel's meters read the same feed.
Browsers start an AudioContext suspended until the user interacts with the page; the app resumes
it on the first pointer or key event, so audio plays from the first preview after any click or
key press.
### GPU
eframe starts on WebGPU where the browser has it (WebGL2 otherwise), and the GPU compositor
(`effectcraft-gpu`) runs on the same device: viewer frames are composited by compute shaders and
drawn straight from their texture, with no readback. The Info panel's pixel readout and the
eyedroppers read GPU frames back asynchronously (`Gpu::read_display_async`: the pixels arrive a
frame later). On WebGL2, or without a usable adapter, everything renders on the CPU.
`effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`.
If the page's WebGPU device is lost (a driver reset, the browser reclaiming the GPU), the canvas
can't show anything new, while the project stays open and commands keep working. The page then
covers the canvas with a notice (`showDeviceLost` in `js/host.js`) offering **Save Project** (a
download) and **Reload**, which first writes pending changes to browser storage
(`effectcraft.flush()`) so the session comes back after the reload; when the write fails it says
so and offers **Reload Anyway**.
**GPU effects run in the frame workers.** A browser never lets JavaScript (or wasm) wait for a GPU
readback, not even in a worker: a buffer maps only once the thread returns to its event loop.
The CPU renderer, which runs GPU effect chains and reads their results back mid-frame, therefore
can't use the page's device. Instead each frame worker opens its own WebGPU device
(`navigator.gpu` in dedicated workers: Chromium, Safari Technology Preview; `Gpu::request_deferred`)
and renders with **deferred readbacks** (`crates/gpu/src/deferred.rs`; job workers do the same,
above):
1. Every readback the renderer needs is keyed by what determines its pixels: a GPU effect chain by
its input buffer's pixels and the chain's parameters, the top-level frame by comp, time and
render options.
2. A key seen for the first time records the GPU work, starts an asynchronous copy (`map_async`)
and *misses*: the step returns a cheap placeholder (its input), and the pass is marked missed.
From the first miss to the end of the pass, the layer cache drops inserts
(`LayerCache::set_gate`), so no placeholder-derived buffer is kept.
3. The worker awaits the device (`Gpu::settled`) and renders the frame again
(`FrameServer::resume`); keys whose bytes arrived are served from them. A pass without a miss
is the frame. Chains that depend on other chains (a precomp with effects inside, effects that
read other layers) take one more pass per level; independent chains resolve together. A chain
whose kernels read data that missed while they ran (through the effect host) is not started,
so a result is only ever computed from genuine inputs. After 24 passes a frame renders on the
worker's CPU.
The whole frame renders on the GPU (the compositor walk with GPU effects, then one readback) when
it can; steps that would need a mid-walk readback (an Advanced 3D run the kernels decline, CPU-only
effects on an adjustment layer) make the frame composite on the CPU instead, with its GPU effect
chains still on the GPU. Backend **Auto** works in the workers: it picks CPU or GPU per comp from
the frames' total times across their passes (`AutoPick`), as on the desktop. Without WebGPU in
workers (Firefox, older Safari) or with `?nogpuworkers`, the workers render on their CPU, as
before. A frame with effects goes to a worker when the workers have a GPU, so the page never runs
an effect on its thread. Particles and Advanced 3D read back under keys too (M13.30, above).
`effectcraft.workerFrameCheck({time?, scale?, backend?})` renders the active comp's frame in a
worker (its GPU by default) and on the page's CPU and reports the differences (`{maxDiff,
meanDiff, over4, workerMs}` in 8-bit levels): the smoke test uses it to check the GPU workers'
pixels.
### Disk cache
Settings ▸ Disk ▸ Disk Cache works in the browser, backed by the **Origin Private File System**
(`src/diskcache.rs`): viewer frames are kept under `effectcraft-cache/v1/frames/<32 hex>.ecc`
between visits, in the desktop cache's format (`disk_cache::frame_entry`: the source files the
frame read with their size and content hash, then 8-bit premultiplied RGBA, LZ4, a checksum),
keyed by the same content hash (from memory: the comp, everything it can draw and what its
expressions can read, frame, scale, view, render options; see
[architecture.md](architecture.md)). The page reads an entry only while the files it lists are
unchanged; a frame whose files can't all be named is not stored. Reload Footage has nothing to
drop here: the file table's identities are content hashes, so a replaced file never matches.
- A render request to a frame worker carries the frame's disk key; the worker posts the frame,
then writes the entry with a **synchronous access handle** into a temporary file moved into
place (`cacheWrite` in `js/host.js`; where `move()` is missing, the entry is written in place
and its checksum catches a partial file), and reports `FrameReply::Stored`.
- The page keeps the index (`DiskIndex`, shared with the desktop's cache): rebuilt at startup from
the folder listing (oldest files first, stale temporaries removed), updated by the workers'
writes and by reads. The size limit is Maximum Disk Cache Size, at most half the origin's quota;
the least recently used entries are deleted beyond it.
- A frame whose key is indexed is read and decoded on the page (asynchronously) instead of being
rendered; a damaged or missing file is a miss and is forgotten. The timeline's blue cache bar
shows the frames on disk.
- `cache.diskStats` reports the browser's cache (`{enabled, available, loaded, entries, frames,
layers, bytes, maxBytes, hits, misses, writes, evictions, layerHits, layerWrites}`); Empty Disk
Cache / `edit.purge {what: "disk"}` clears it. `effectcraft.info().diskCache` has the same.
**Layer buffers (M13.30).** Slow layer buffers (≥ 20 ms to render) go to the disk cache too
(`effectcraft-cache/v1/layers/<32 hex>.ecc`, `disk_cache::layer_entry`, keyed as on the desktop:
`disk_cache::layer_disk_key`; each entry keeps the source files its buffer was made of, with
the file table's size and content hash, and is used only while they are unchanged). A layer lookup happens in the middle of a synchronous render,
where the browser's file system can't be read, so it is made synchronous by fetching first:
1. Each frame worker backs its layer cache with a `PrefetchStore` (`effectcraft_render::cache`):
lookups are served from buffers fetched before the request; a lookup that finds nothing is
recorded, and buffers to keep queue up as sealed entries.
2. The reply lists the frame's layer misses and the buffers served from the store
(`FrameReply::Frame` `layerMisses` / `layerHits`); after posting the frame the worker writes
the queued entries (sync access handles) and reports each (`FrameReply::Stored` `layer`).
3. The page keeps one index for frames and layers (`DiskIndex`: one least-recently-used order;
hits touch entries) and remembers the workers' recent misses (`disk_cache::LayerPrefetch`).
A render request names the recent misses that are on disk (`FrameMsg::Render` `prefetch`,
at most 64); the worker reads those it lacks into its store before rendering, so their
lookups hit (another worker's buffers, buffers from an earlier visit, buffers dropped by a
purge).
### Storage manager
Settings ▸ Disk ▸ **Browser Storage** (shown in the browser only) reports where the data lives
(Origin Private File System, IndexedDB or memory), the origin's usage and quota
(`navigator.storage.estimate()`), what the store holds (projects, imported media, auto-saves:
count and size) and the disk cache, whether the storage is persistent (with **Request Persistent
Storage**, `navigator.storage.persist()`), and **Clear** buttons for the disk cache, imported
media, auto-saves and saved projects. Agents use the same through engine commands
(`crates/engine/src/storage.rs`, the session's `StorageHost`):
| Command | |
|---|---|
| `storage.info` | `{available, backend, usage, quota, persisted, files: {projects, media, autoSaves: {count, bytes}}, diskCache: {...}}` (`{available: false}` on the desktop) |
| `storage.persist` | ask for persistent storage; `persisted` in a later `storage.info` says whether it was granted |
| `storage.clear {what: diskCache\|media\|projects\|autoSaves\|all}` | delete them (settings stay); `{cleared, entries, bytes}` |
The widgets have automation ids `settings.storage.backend`, `.usage`, `.diskCache`, `.persist`
and `.clear.<what>`.
### Offline and install (PWA)
`manifest.webmanifest` (name, icons, standalone display) makes the app installable; `sw.js`
precaches every file of the build into a cache named after the build's hash, serves them
cache-first (the cached responses keep the server's COOP/COEP headers, so the page stays
cross-origin isolated offline), and drops older caches when a new build activates. After the
first visit the app loads without a network.
## `window.effectcraft`: the agent / test API
The control channel of the desktop app (`docs/control-protocol.md`) as promises. Results resolve
with the method's `result`, errors reject with the message.
| Call | |
|---|---|
| `effectcraft.request(method, params)` | any control method: `ui.inspect`, `ui.click`, `ui.key`, `ui.playback`, `ui.menu.invoke`, `render.frame`… |
| `effectcraft.execute(command, params)` | `engine.execute`: an engine command (same ids and params as MCP / `effectcraft-cli`) |
| `effectcraft.commands()` / `effectcraft.inspect()` | `engine.commands` / `ui.inspect` |
| `effectcraft.renderFrame(params)` | `render.frame` with `base64: true`: `{comp, time, width, height, png}` |
| `effectcraft.screenshot(params)` | `ui.screenshot`; without `path` the PNG comes back inline as `png` (base64) |
| `effectcraft.addFile(fileOrUrl, name?)` | put a `File`/`Blob` (or fetched URL) into the file table (stored) and open (`.ecproj`) or import it; resolves with its path |
| `effectcraft.files()` / `effectcraft.readFile(path)` | the file table `[{path, size}]` / a file's bytes (`Uint8Array`), e.g. a render |
| `effectcraft.saveToBrowser(path?)` | save the project to browser storage without downloading it (default: its path, or `/<name>.ecproj`); resolves with `{path, bytes}` |
| `effectcraft.listStored()` | `{backend, usage, quota, persisted, pending, files: [{path, size, modified}], config: [name]}` |
| `effectcraft.removeStored(path)` | delete a stored file |
| `effectcraft.flush()` | resolves once every change is written to browser storage; rejects when a write failed (quota exceeded…: the change stays pending and is retried) |
| `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `frameWorkers`, `diskCache`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings |
| `effectcraft.workerFrameCheck(params)` | a frame rendered in a frame worker (its GPU by default) against the page's CPU render: `{width, height, maxDiff, meanDiff, over4, workerMs}` |
`window.effectcraftLoad` holds `{wasmMs, readyMs}` (module fetch + compile, and until the app runs).
```js
await effectcraft.execute("layer.newSolid", {color: "#ff8800"});
await effectcraft.request("ui.menu.invoke", {id: "renderQueue.add", params: {format: "gif", resolution: 0.25}});
await effectcraft.execute("renderQueue.render", {wait: false}); // renders in a worker; the GIF downloads
await effectcraft.saveToBrowser("/my-project.ecproj"); // File ▸ Open Recent has it after a reload
```
## Browser test
`apps/effectcraft-web/tests/smoke.mjs` drives headless Chrome over the DevTools protocol (Node ≥ 22,
no npm packages): load, the GPU path (WebGPU → GPU compositor, viewer on the GPU), the demo comp
in the viewer, `render.frame`, Render Queue GIF and PNG sequence (`.zip`) downloads, a background
render in a worker while measuring the page's event-loop gaps (must stay under 400 ms) and its
progress, the AudioContext starting on a (simulated) key press and a tone playing through it
with the meters moving, Save As and re-opening the saved project, a script opening a file from
browser storage and the footage check finding stored media, persistence across a reload
(session snapshot, a stored project, imported media, a setting, Open Recent) and an offline
reload through the service worker. It writes screenshots and `report.json`:
```sh
cargo xtask web --serve 8765 &
node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke
```
`apps/effectcraft-web/tests/workers.mjs` checks the job-worker plumbing (`js/host.js`,
`web/worker.js`) under Node with a fake `Worker`, without a build: a replaced file is sent to a
reused worker again even at the same size, a worker that fails to start fails its job and is
terminated, and a `new Worker` that throws (a sandboxed iframe) fails the job and reports the
frame worker dead instead of stopping startup (`node apps/effectcraft-web/tests/workers.mjs`).
`apps/effectcraft-web/tests/page.mjs` checks page-side helpers of `js/host.js` the same way with
a fake DOM: a picked file that can't be read is reported while the others are added, and the
device-lost notice saves, flushes before reloading and reports a failed flush
(`node apps/effectcraft-web/tests/page.mjs`).
`apps/effectcraft-web/tests/storage.mjs` checks the IndexedDB storage backend of `js/host.js`
with a fake IndexedDB: a put or delete resolves only once its transaction has committed, and one
whose transaction aborts after its request succeeded (or whose request fails or throws) rejects
and leaves the stored entry as it was, so the flush keeps it for a retry (#1242)
(`node apps/effectcraft-web/tests/storage.mjs`).
It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the
CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that
replies when done while the page keeps running, and the Media Browser over browser storage; and
the M13.24 ones: GPU effects (Gaussian Blur, Glow) rendered on the frame workers' own WebGPU
devices in passes while the page's event loop stays free, the worker's GPU frame matching the
page's CPU render (`workerFrameCheck`), the storage manager (`storage.info`, `storage.persist`,
the Settings ▸ Disk widgets), and the disk cache: frames written to the Origin Private File
System, served from it after a reload without rendering, and cleared with `storage.clear`; and
the M13.30 ones: a Render Queue job and a Warp Stabilizer analysis on the job worker's own
WebGPU device (`workers.jobs`: GPU passes and readbacks per job) while the page stays
responsive, and layer buffers written to the disk cache and served from it after a reload and a
purge (`layerHits`); M13.32: editing after the Warp Stabilizer analysis (removing it,
pre-composing, a mask) keeps the page's event-loop gaps under 50 ms.
Native unit tests cover the storage model (`apps/effectcraft-web/src/store.rs`: write
coalescing, file table, `ConfigStore` / `FileOps`, auto-save and crash recovery through the
browser store) and the worker protocol (`crates/engine/src/offload.rs`: serde round trips of
every request and reply, a render through an in-process offload; `tests_roto.rs`: Roto Brush
propagation through an offload streaming segmentations; `tests_mask_warp.rs`: Warp Stabilizer
plans solved in the job, never by `warp.status` on the page), the frame-worker protocol
(`crates/engine/src/remote.rs`: diff / patch, a replica following edits renders the same pixels;
`crates/ui-egui/tests/it/ui_web_offload.rs`: the frame cache through a remote renderer, `wait: true`
deferred until the offloaded job ends) and the Media Browser's virtual tree (`media_browser.rs`,
`tests_panels.rs`). M13.24: deferred readbacks on a real device (`crates/gpu/src/tests_deferred.rs`:
whole-frame GPU and CPU-composited frames in passes match the CPU, placeholders never reach the
layer cache; `deferred.rs`: keys, late readbacks of an older frame dropped), the frame server's
passes and Auto timing (`remote.rs`, a deferred stand-in accelerator), the browser's routing and
disk cache (`crates/ui-egui/tests/it/ui_web_gpu_storage.rs`: a GPU frame worker renders effect frames,
the page's GPU keeps frames without effects, frames stored under their disk keys come back after a
"reload" without rendering, the blue cache bar sees them; the storage manager's widgets and
commands), the disk index (`disk_cache.rs`), the store's usage by kind and clearing (`store.rs`),
`storage.*` (`storage.rs`), and every WGSL float literal fitting f32 (browsers reject the whole
module otherwise; `tests.rs`). M13.30: job workers on a deferred device
(`crates/ui-egui/tests/it/ui_web_job_gpu.rs`: a Render Queue job and a Warp Stabilizer analysis
render their frames in passes on the device, with the CPU worker's pixels / result),
particles, Advanced 3D and the job pass driver in passes (`crates/gpu/src/tests_deferred_jobs.rs`),
layer buffers through the disk store and the prefetch plan (`remote.rs`
`layer_buffers_go_through_the_disk_store`).
## Gaps
- No shared-memory threads: each worker is a separate engine instance (memory per worker: the
project replica and the footage it reads); see the threading design above.
- Browsers without WebGPU in workers render frames, Render Queue jobs and analyses on the
workers' CPU. A frame renders at most 24 passes on the GPU, then on the CPU.
- A layer buffer is fetched from disk only after some worker missed it in a recent frame (the
first frame after a reload renders its layers).
- Cancelling an analysis drops its partial result (the worker is terminated).