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
Dragging the in or out edge of a selected layer's bar now trims every
selected layer by the same delta from its own in/out point, as in After
Effects. layer.timing gains relative inDelta/outDelta params (seconds,
frame-snapped), clamped to the comp start/end and to one frame of layer;
the drag stays one undo step via the merge key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
Preserve legacy selection repair, modifier routing, viewer lifecycle and workspace persistence through the shared docking integration. Qualify the complete workspace gate by refreshing the 18 stale Audio Spectrum hashes for the existing linear renderer, with an independent synthetic-tone regression.
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Cause: at Half, Third and Quarter resolution (the viewer's Auto resolution
in most windows) every movie frame was converted from Y'CbCr to f32 RGBA at
full size and then resampled down. In the browser (wasm, one thread) that
was about 8 ms converting a 1080p frame, 6 ms resampling it and 2 ms
zeroing a 33 MB buffer, per layer and frame, for a frame shown at a
quarter or a ninth of its pixels; the footage frame cache held the
full-size frames.
Fix: FootageSource::frame_at_size asks for a frame shown at a given size
(default: the full frame, as before). The renderer asks for it where it
resampled footage (below 3/4 scale; not for proxies, fields or Interpret
Footage's pixel options, which need the full frame). MediaPool makes opaque
4:2:0 movie frames of the footage's own size there: it box-filters them to
half size in Y'CbCr before converting (each 2x2 block of luma, and the
[1 6 1]/8 neighbourhood of its chroma sample, which is the mean of the
upsampled chroma its pixels get), resamples the rest of the way and caches
the result per size. Conversion is linear until colours clip, so this is
the RGB box filter resample starts with, except in blocks where some
colours clip. Other frames convert at full size and resample as before.
In the browser the conversion and resampling take about 4 ms instead of
16. Docs (architecture).
Tests:
- convert::scaled_conversion_matches_resampling_the_converted_frame: half,
third, quarter and odd sizes of an unclipped 4:2:0 frame are within 1e-5
of resampling the converted frame; larger sizes, odd frame sizes and
4:4:4 convert at full size.
- tests_footage_size::reduced_renders_take_footage_resampled_by_the_source:
a source that resamples frames itself renders the same frames as one
that doesn't, at 8 and 32 bpc and every resolution, and is asked only
below 3/4 scale, and not with Invert Alpha.
- footage::h264_frames_at_reduced_sizes (ffmpeg fixture): 1080p H.264
testsrc2 frames, whose saturated colours clip, at 960x540, 640x360,
480x270 and 323x182 are 58-62 dB from the resampled full frames, and
are cached per size.
- convert's ignored 1080p bench also times the conversion at a third.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
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 <[email protected]>
Cause: Auto dropped to Half below 75 % magnification, so text between
50 % and 75 % was rendered with fewer pixels than the screen shows and
looked soft. Below 100 % the frame texture was minified with a plain
bilinear sampler and no mip levels (Full at 46 % skipped most frame
pixels), so edges stair-stepped.
Fix:
- Auto renders the pixels the magnification needs, as in After Effects:
the coarsest whole factor that still gives every screen pixel a
rendered one (Full above 50 %, Half down to 33.3 %, Third down to
25 %, Quarter below).
- GPU frames: the display texture carries a full mip chain, each level
the 2 x 2 mean of the one above (display.wgsl `mip`, same bind group
layout as `display`), sampled trilinearly with Viewer Zoom Quality
More Accurate (Faster keeps the nearest pixel of the full-size frame).
- CPU frames (egui-wgpu textures have no mips): uploaded averaged down
by the whole factor that leaves about one texel per screen pixel, the
same box average as the texture-limit fit (#201), re-uploaded when the
magnification calls for another factor; drawn with texture coordinates
that keep each texel over its pixels when the size isn't a multiple.
Show Channel / exposure textures follow.
Tests: gpu tests::display_frames_are_mipmapped (level count and level 1
= 2 x 2 means of level 0), viewer_tools
auto_resolution_follows_the_magnification (60 % was Half) and
minified_frames_keep_their_texels_in_place.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Text is vector in After Effects and stays sharp at any Scale. Here a
text layer was drawn at its font size and the bitmap resampled by Scale
unless Continuously Rasterize was on, so text grown with Scale went soft.
This reworks #200 (reverted in 29c4d3a for kinetic_typography).
Why kinetic_typography failed: its time remap key at 1.9 s sits on
frame 23 (1.917 s), so main-comp 0.3 s shows the precomp at 1.6026 s,
but the test compared it with the precomp rendered at 1.6 s. The moving,
motion-blurred title already differed by 0.89 of the 1.0 mean-difference
budget on main (0.987 in all); with continuous rasterisation the "Sub"
line, whose expression scale is 84.00 % at 1.6 s and 84.105 % at
1.6026 s, falls on either side of the 2^-1/4 quarter-octave step and was
drawn at 0.84 at one time and at 1.0 at the other, pushing the total to
1.075.
Fix: raster_scale treats text layers as always continuously rasterised
(shape layers and vector footage still follow the switch), and rounds
the quarter-octave step up past rounding noise only: an unscaled layer
at a fractional position measured its scale as 1 + 1e-15 and was
rasterised at 2^1/4, softening unscaled text. The QA test now compares
the remapped precomp with the precomp at the time the remap really
shows (read back from the property): difference 0.63, all of it the
title's motion blur, the Sub line identical.
Tests: tests_collapse::text_scaled_up_stays_sharp_without_the_switch
(from #200: 10 px text at 800 % matches 80 px text),
tests_collapse::unscaled_text_rasterises_at_the_output_scale (fails
without the noise tolerance), automation qa kinetic_typography.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Cause: the interior styles were blended over the layer body with the
content's coverage folded into their opacity. On an edge pixel at 50 %
coverage a Gradient or Color Overlay went over the half-transparent body
as a half-transparent layer, so the result kept a quarter of the layer's
own fill and its alpha rose to 0.75: a light rim (the white fill
showing through) on styled text.
Fix: blend_clipped composites the interior styles (Gradient Overlay,
Color Overlay, Satin, Inner Glow, Inner Shadow and the inside of
Stroke) onto the layer within its shape: the body is divided by the
content coverage, blended with the style at its own opacity, and
multiplied back, so the alpha is kept and an edge pixel mixes the style
with the layer's colour inside the shape only, as a clipped layer does.
Fill Opacity and Blend Interior Styles as Group behave as before.
Tests: tests_styles::overlays_leave_antialiased_edges_clean (a masked
square with half-covered edge columns; Color Overlay, Gradient Overlay
and an inside Stroke now match a plain layer of the overlay colour;
Color Overlay's edge was 0.41 red against 0.23 before).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Cause: pipeline::Cx rendered with Backend::Auto, which picks the CPU or the
GPU compositor for each top-level frame from measured frame times
(render::AutoPick: warm-up on both sides, a re-probe every 48 frames). The
two compositors differ slightly, so frames came out differently depending
on which side the timings favoured in that run, and two GPU exports of the
same frames differed.
Fix: the job renders every frame on the project's renderer, as After
Effects renders a job with one renderer: Backend::Gpu with Mercury GPU
Acceleration (frames the GPU declines still render on the CPU, as they do
in every GPU render), Backend::Cpu with Mercury Software Only. The viewer
and previews keep Auto. Docs (architecture, parity, web) say so.
Tests: render_settings::a_job_renders_on_one_compositor exports ten frames
with a stand-in GPU compositor that keeps Auto history and draws solid
green: with Mercury GPU Acceleration every frame is green and two runs are
identical (with Auto the warm-up sent frames to the CPU: it fails), with
Software Only the stand-in is never asked. The web worker GPU job test's
comment no longer describes Auto.
Diagnosis by @pat-the-wizard.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Cause: pipeline::Cx::render_at built the job's Renderer without a layer
cache, so every layer of every frame rendered from scratch (source, masks,
effects, styles), and the GPU compositor, whose uploads are keyed by the
cached buffers, uploaded every layer again on every frame.
Fix: each job (Cx) owns a LayerCache, the cache type the viewer uses, with
its default budget (1 GiB, the default Settings > Memory & CPU layer cache),
shared by the job's parallel frames and dropped with the job. With deferred
GPU readbacks it holds the accelerator's miss gate, as the job workers'
caches do. Cx::renderer builds the job's renderer (options, accelerator,
cache) for render_at and the test.
Tests: pipeline::tests::layers_render_once_per_job exports ten frames of a
solid animated only in Transform: the cache misses of the first frame are
the only ones (it fails without the cache: no layer is cached), and every
frame equals the frame rendered without the cache.
Diagnosis by @pat-the-wizard.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
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
The QA test's time-remapped precomp no longer matches the precomp rendered
directly (crates/automation/tests/qa/kinetic.rs:78); FreeBSD CI has been red
since. Reverting until the PR is reworked against that test.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Imported GLB/glTF models were invisible in the Composition viewer while
text showed. Only the Advanced 3D renderer draws model and primitive
layers, and new compositions (and New Comp from Selection) are Classic
3D by default, but adding a model layer left the comp's renderer alone,
so the model drew nothing on every backend. It wasn't the reporter's
GPU: on this machine the viewer showed nothing for a GLB in a new comp
with the GPU compositor (Vulkan) and with Mercury Software Only, and
the model in both once the comp used Advanced 3D.
As After Effects documents ("Advanced 3D gets automatically selected
when you add a 3D model to the composition"), layer.newModel /
layer.addItem of a model and layer.newPrimitive now switch a Classic
3D comp to Advanced 3D inside the same edit (one undo step, Geometry
Options added to its text and shape layers like Composition Settings
does) and toast that the comp changed renderer. Switching a comp that
has model layers back to Classic 3D toasts why they disappear.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Shape and text layers have comp-sized effect bounds centred on their
origin, but effects measured positions from the origin: a point at its
default "layer centre" sat at the comp's bottom-right corner, so Twirl
on a centred shape changed nothing and Bulge only its bottom-right
quarter. As in After Effects, effects now work in effect space,
measured from the top-left of the layer's effect bounds.
The renderer moves the layer buffer into effect space for the whole
stack (Buf::rebase, CPU and GPU alike) and hands effects everything
else in it: masks, other layers' pixels for layer parameters (each in
its own effect space, so Set Matte and displacement maps see a whole
shape layer), the layer's own frames (layer_input, also what Puppet,
Roto Brush and the Layer panel read) and the comp camera
(CompScene::rebased). Layer bounds are now right for every effect, so
pin_rect and EffectEnv::bounds_origin go and layer_rect serves edge
pinning too. Adjustment layers still run on the comp below as it is.
effect_bounds / EvalCtx::effect_to_comp replace the repeated "comp size
when there is no source" code; the viewer's effect point handles and
eyedroppers, Puppet pins, the Layer panel (paint, Roto Brush) and the
tracker's Corner Pin output use them.
Project schema 2: opening a schema 1 project moves positions held by
effects on shape and text layers by half the comp size, so set points
keep their place (point parameters with keyframes, Paint paths and
clone positions, Puppet seeds and pins, Liquify and Roto Brush
strokes). Points still at their default become the layer centre;
expression controls, adjustment layers and expressions are left as
they are (docs/architecture.md). The disk cache's render version is
bumped.
Tests: default_effect_points_sit_at_the_centre_of_a_shape_layer and
layer_parameters_see_a_whole_shape_layer (render, both fail before),
effect_points_on_a_shape_layer (GPU vs CPU),
opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space
(engine), and the effects migration tests.
Part of #227
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A text layer was drawn at its font size and the bitmap resampled by the
layer's Scale unless its Continuously Rasterize switch was on, so text
grown with Scale (the usual way to animate it) went soft. Text layers now
always rasterise at their on-screen scale, as they would with the switch;
shape layers and vector footage still follow the switch.
Text at 10 px scaled 800% now renders identically to text set at 80 px
(795 partially covered pixels each, maximum difference 0; it was 5854
against 795 before).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The desktop app shares egui-wgpu's device, which had no uncaptured-error
handler, so wgpu's default one panicked on an out-of-memory error on
every frame thread; the panicking frame stayed "in progress" forever and
never rendered again. The GPU frame budget was a fixed 1 GiB.
- GpuContext::new logs uncaptured errors once its pipelines are built
(errors while building them still panic there, which the app catches
to fall back to CPU compositing).
- Gpu::within_memory runs GPU work in an out-of-memory error scope; on an
error the result is discarded and uploaded layers and pooled textures
are freed.
- Frame workers render each frame within it; out of memory, the frame
renders on the CPU and the RAM preview keeps half as many GPU frames
(down to 64 MiB), since wgpu can't tell how much video memory there is.
- A frame whose render panics renders again on the CPU, or is released,
so the viewer asks for it again.
Tests: a_frame_whose_render_panics_is_released (failed before),
running_out_of_video_memory_halves_the_gpu_frame_budget and
within_memory_returns_the_work_when_there_is_room.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Toggling a layer's Audio, Lock or Shy switch (or Hide Shy Layers) threw
away every cached frame: the RAM preview identity hashes each comp's
address, which any edit replaces, and the layer and disk cache keys
hashed every switch. None of these change pixels.
- Switches::pixels, Layer::same_pixels and Comp::same_pixels (exhaustive
field lists, so a new field must be considered) and Comp::pixel_form.
- Layer cache keys hash Switches::pixels; the disk cache's comp content
key hashes Comp::pixel_form.
- Frames::identity: when a new revision's comps draw the same pixels as
those of the comp's last new identity, that identity (and the project
holding its comps' addresses) is kept, so the frames stay, also when the
switch is toggled back.
With audio on, playback skipped frames that weren't rendered yet. Now the
sound waits: until the frames ahead (to the end of the preview, or what
fits in the RAM preview) are cached, every frame shows as it renders,
silently, like a preview without audio; then the sound starts in sync.
Reaching an uncached frame stops the sound instead of skipping frames.
Tests: audio_lock_and_shy_switches_keep_the_frames (frames.rs, nested
comps and toggling back), audio_lock_and_shy_keep_cached_layers (engine),
the disk key in tests_disk_cache, and
preview_with_audio_shows_every_frame_and_sounds_once_cached.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
gaps.md records the Windows report and its fix under real-user experience and G2; parity.md's
Text row, the ROADMAP's reliability note and the README's Text bullet say text can use any
installed font; architecture.md says when the system font scan runs.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019EjTYdt76hTG4Kh2CJFnCr
- effectcraft-segment: a FaceModel interface (find a face in a region, follow it; a Topology
maps the model's points to the tracker's landmarks and outline), a TensorFlow Lite reader and
interpreter (float ops, float16 weights folded; matches ai-edge-litert to ~1e-6), and
MediaPipe Face Landmarker (Google, Apache-2.0: BlazeFace + Face Mesh V2, 3.8 MB, pinned
SHA-256). The registry gains a task and authors per model; installed weights get a NOTICE.
- effectcraft-track: FaceTracker::new_with uses the model, falls back to the classical fitter
when it finds no face; chin and jaw come from the outline either way so measurements agree.
- Engine: roto_models becomes models (both tasks); face.models / face.model.* commands,
Settings ▸ Face Tracking, `wait` on select/install/download for scripts; track.mask reports
faceModel.
- Credits: ATTRIBUTION.md lists the optional models and the Apache-2.0 specs the code follows.
- Docs: README, ROADMAP (G7), gaps, parity (TRK-3 score unchanged pending G1), agents,
preferences, architecture, segment README.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- New L1 crate effectcraft-segment: the MaskModel interface (prompts → foreground probability),
a registry of open-licensed models (licence must be MIT/Apache/BSD/…; URL, size, SHA-256),
a PyTorch checkpoint reader (zip + pickle subset), dependency-free SHA-256, and MobileSAM
(TinyViT-5M + SAM prompt encoder / two-way transformer / mask decoder) on rayon and ndarray's
safe GEMM. Loads the official mobile_sam.pt as published; checked against candle
(embedding 0.3% RMS, 99.9% equal mask pixels); ~0.7 s/frame encoder at 32 threads, cached
per frame, ~0.1 s decoder.
- track::roto: segment_with / propagate_with prompt a model with the strokes or the flow-warped
matte (box, core points, mask); its probability is a strong, unblurred prior for the same
graph cut (strokes stay hard constraints); a failing model or one disagreeing with the flow
(IoU < 0.5) is ignored. Prior gets a weight.
- effects::roto: Version 2.0 / 3.0 use the model chosen in Settings (set_model / model_for);
the model id is part of the chain seed.
- engine: roto_models (roto.models, roto.model.select / install / download / remove), weights in
the models folder (host::config_dir()/models, shared by the app, CLI and MCP), background
loading, downloads through the system curl, verified; Settings ▸ Roto Brush page (prefs
roto.model). host::config_dir moved from the desktop app.
- Tests: segment (kernels vs direct sums, checkpoint reader, SHA-256 vectors, registry), track
(fake model: prior shapes the cut, failing / disagreeing models ignored; real weights with
EFFECTCRAFT_MOBILESAM: base IoU 0.989, propagated ≥ 0.980 vs classic 0.973), engine (registry,
verification, selection; real install + load), MCP.
- Docs: README, ROADMAP, parity (MSK-4 done: 90/2/0), preferences, agents, architecture,
crates/segment/README.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- `FrameKey` carries a hash of the render options (`frames::opts_hash`): switching Fast
Previews (Draft / Fast Draft) or Realtime Shadows no longer shows frames rendered the other
way.
- Eviction drops the least recently shown frame (`Frames::get` touches it), not the oldest
inserted: a looping preview a little over budget no longer evicts the frame it shows next.
- Frames of earlier project revisions are dropped on every change (`Frames::drop_stale`); they
could never be shown again but held memory (and GPU textures) until evicted.
- The green cache bars (Timeline, Preview panel) count the frames of exactly what the viewer
shows — scale, 3D view / region of interest and options (`EffectcraftApp::shown_series`) —
instead of any frame at that scale.
- Playback loops compute the key's fixed part once (`EffectcraftApp::frame_series`).
- Docs: architecture.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- `Project::to_file_json` writes `savedBy` (the EffectCraft version) ahead of the project and
checks that the text reads back before anything is written: JSON has no NaN or infinity, so
such a value made a file that saved but could never be opened. Save now fails with a message
and the file on disk is left as it was.
- Opening a project saved by a newer EffectCraft warns that settings this version doesn't know
are lost on saving; `file.open` reports `savedBy`. A file that isn't a project names itself in
the error.
- Increment and Save keeps the extension (`.ecprojx` stays XML); Save a Copy writes XML for
`.ecprojx` / `.xml` paths, like Save As.
- Docs: architecture (document model), parity.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Quit and the window's close button, File ▸ New Project, Open Project, Open Recent, Close
Project, the demo project and a Home template show Save / Don't Save / Cancel when the
project has unsaved changes (Save asks for a path when untitled; cancelling that keeps the
project open). File ▸ Revert asks to discard. Before, all of them dropped unsaved work
without asking.
- The window title shows the project's path and `*` while it is modified.
- The modified mark follows undo: the session keeps the snapshot it last opened or saved
(`Session::saved_project`), so undoing, jumping in the History panel or rolling back a batch
to the saved state clears it.
- An edit that changes nothing (a value set to what it already is) records no undo step and
doesn't mark the project modified (`Project::same_content`, comparing untouched comps by
pointer).
- Agents' `engine.execute` / MCP calls are never asked; `ui.menu.invoke` shows the prompt like a
click (automation ids `dialog.unsaved.save` / `dontSave` / `revert` / `cancel`), and the
control channel's `app.quit {force: true}` skips it.
- Docs: architecture, agents, control protocol, parity.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
GPU render passes for CC Particle World / Systems II, Particle Playground,
CC Ball Action, CC Pixel Polly, CC Scatterize and Curl Noise (fx_particles,
chunked multi-pass raster); Shatter wireframe views, Foam extras, Timewarp
with a Matte Layer, strong Warp Fisheye/Twist (CPU f64 inverse map) and
custom .ocio configs no longer fall back to the CPU. CPU refactors pinned by
particle_golden / timewarp_golden / sim_golden hashes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- GPU in the browser: each frame worker opens its own WebGPU device (Gpu::request_deferred) and
renders the GPU compositor and GPU effects with deferred readbacks (effectcraft-gpu deferred):
readbacks keyed by content start with map_async and miss, the pass returns placeholders, the
layer cache's gate drops inserts, and FrameServer::resume renders again once the device
delivered. Backend Auto times whole frames per comp in the workers. Frames with effects go to
GPU workers instead of the page. CPU fallback without WebGPU in workers (?nogpuworkers).
- Fix: a WGSL constant (3.40282347e38) that Chrome rejects invalidated every GPU kernel in Chrome
(the page's compositor too); a test checks every float literal fits f32.
- Disk cache in the Origin Private File System: workers write sealed frame entries with sync
access handles (temp file + move), the page keeps the DiskIndex (LRU under the settings' limit,
at most half the quota) and reads hits instead of rendering; survives reloads; blue cache bar.
- Storage manager: Settings ▸ Disk ▸ Browser Storage (usage/quota, persistent storage, Clear
buttons, automation ids) and storage.info / storage.persist / storage.clear (StorageHost).
- Docs: web.md (GPU workers, deferred passes, disk cache, storage manager, threading design:
one engine instance per worker), architecture, preferences, parity (WEB-1), roadmap.
- Tests: deferred passes vs CPU on a real device, placeholder gating, frame server passes and
Auto, GPU worker routing + disk cache across a reload, storage commands and widgets, store
usage by kind; smoke test: GPU effect frames in workers with pixel parity, disk hits after a
reload, storage manager.
Co-Authored-By: Claude Opus 5.5 <[email protected]>