Premation — Editor Reference

The prose is not under test — see §0. A verification pass on 2026-08-10 found four false claims in §3/§4 and they are recorded in §5.


0. How this document is kept true#

The previous reference (PREMATION_COMPLETE_REFERENCE.md, deleted) claimed 38 effects in one table and 58 in another while EffectType held 73. It declared trim and repeater permanently outside the path-operator chain months after they joined it. It listed continuous rasterization as the last unbuilt gap when the feature ships with a renderer read path and an inspector control. Three audits re-derived the same numbers by hand and got three different answers.

So the numbers are no longer written by hand:

node scripts/featureCounts.cjs --verbose

src/__tests__/docFeatureCounts.test.ts parses the table in §1 and fails the build when it disagrees with the registries. Adding an effect reddens the suite until this file is updated — which is the point.

What the pinning does NOT cover — read this before trusting a sentence#

Only the §1 table is under test. Every other line is prose, and prose is where the errors actually were.

This is not a hypothetical caveat. A verification pass run the day after this document was written found four false claims in §3/§4 — a fabricated template capability, a "dead" toggle that had already been deleted, an architectural claim about lighting that was the opposite of the truth, and a subsystem described as working that has no implementation. Two of them were inherited verbatim from the deleted predecessor and restated here without being checked, which is precisely the failure this document was created to end.

So the rule is narrower than it looks:

  • A number in §1 is a number a test is holding down. Trust it.
  • A sentence anywhere is a claim someone believed on the day they wrote it. Verify it against the code before acting on it, and if it disagrees with the code, the code wins and §5 gets a new row.

Prose cannot be pinned the way counts can. What §5 buys instead is that a claim, once disproved, stays disproved — it is there so a superseded statement is not rediscovered in git history and believed a second time.


1. Feature counts#

Registry Count Source of truth
Effects 183 src/core/effects/effects.tsEffectType
Blend modes 38 src/core/effects/blendMode.tsLayerBlendMode
Layer styles 10 layerStyles.tsLAYER_STYLE_LABEL + BACKDROP_STYLES
Path operators 9 src/core/scene/pathOps.tsPathOpType (less none)
Mask modes 7 src/core/effects/mask.tsMaskMode
Light types 5 src/core/scene/light.tsLightType
Canvas tools 22 packages/workspace/src/tools/builtin.ts
AI tools 65 packages/ai-tools/src/tools/{read,write,craft,compose}.ts
Export formats 18 videoSink.tsVideoFormat + exportManager.tsExportFormat
Stores 61 src/stores/*.ts
Packages 13 packages/*

Layer styles come from two registries. Most compile to an effect and live in LAYER_STYLE_LABEL; Glass cannot, because it is a function of what is composited behind the layer and so resolves onto the renderable (glassResolve.ts). It lives in BACKDROP_STYLES, and the script sums the two.

It used to append a literal 'glass' instead — a hand-written number inside the script that exists to eliminate hand-written numbers. A second backdrop-resolved style would have left this table wrong with every test still green.


2. Architecture#

Electron main ── IPC ──▶ renderer (React 19 + Vite)
                          │
                          ├── src/stores/*        61 Zustand stores
                          ├── src/core/*          41 subsystems (effects, scene, rig, text…)
                          └── packages/*          13 workspace packages
                                ├── scene       scene graph + components
                                ├── animation   tracks, easing, expressions
                                ├── renderer    WebGPU → WebGL2 → Null
                                ├── workspace   tools, selection, gizmos
                                ├── timeline    timeline model
                                ├── ai-tools    the 65-tool registry
                                ├── caster      deterministic technique caster
                                ├── technique-library
                                ├── product-motion, audio, design-system, render-tests

One engine for preview and export. src/core/export/offlineRenderer.ts calls the same createRenderBackend + buildSnapshot pair the viewport uses, on a fixed timestep (frame index / fps). There is no separate export renderer, so the "final render looks different from the preview" class of bug does not exist here. Backed by real-GPU golden-image tests in packages/render-tests.

Backend selection (createRenderBackend.ts): WebGPU when navigator.gpu exists → WebGL2 → Null for headless/tests.

The render path, end to end#

scene graph ──▶ buildSnapshot ──▶ snapshotToFrameScene ──▶ rendergraph passes
   (nodes)        (per-frame        (render structs)         Clear · Background
                   sampling of                               Composition · Mask
                   every track)                              Effect · Selection · Overlay

buildSnapshot is where per-frame resolution happens — it samples every animated track, resolves expressions, computes shading multipliers and DOF blur, and folds the result into a content hash used for raster caching.


3. What the editor does#

Verified present with a reader in the render path, not merely declared.

Animation model#

Keyframes with bezier/hold/linear interpolation, a value + speed graph editor, keyframe-selection time scaling, roving, and Easy Ease assistants. Expressions are a hand-written language (packages/animation/src/expressions.ts, ~970 lines) with cycle detection and a depth cap — not new Function, which is what lets them run under the app's CSP. Step and depth budgets guard against main-thread DoS from nested wiggle() octaves.

The expression API is much wider than "curated" suggests — ~50 identifiers, not the "~18 functions" earlier docs claimed. It includes the whole time-sampling set the AE idiom library is built on: valueAtTime, velocityAtTime, velocity, speed, key(n), nearestKey(), numKeys, timeToFrames, framesToTime, loopIn/loopOut, sourceRectAtTime, posterizeTime, seedRandom/gaussRandom, vector maths (add/sub/mul/div/dot/cross/normalize/length), layer-space conversion, thisLayer/thisProperty/thisComp, markers and audio.

That matters more than the count: the standard AE bounce expression, inertial follow and delayed-child rigs are all built on velocityAtTime + key() + numKeys, and all three primitives are present, so that class of expression ports as-is. There is no architectural limit on sampling a track away from the current frame — sampleRaw reads keyframes only, deliberately bypassing the expression so valueAtTime cannot recurse through itself.

Named easing presets ship (2026-08-17, easePresets.ts): 8 families × 3 directions, applied through the same applyEasingToKeyframes entry point as F9 and the timeline pills, with a curve grid in the Graph panel. EasingPreset widened to admit their ids, so every easing surface resolves them identically and they inherit the data-track and merged-Position handling for free.

Elastic and Bounce are deliberately NOT in that table. Both oscillate around their target with decaying amplitude, which no single cubic segment can trace — BOUNCE_EASE in animationPresets.ts is one cubic-bezier (0.175, 0.885, 0.32, 1.275) commented "Elastic bounce", and a bezier has exactly one overshoot, so the name overstates it. They are GENERATORS and live in bounce.ts. A test guards their absence, so the next reader does not "fix" it by adding a lookalike bezier.

Bounce is a keyframe assistant (bounceTracks / bounceKeyframes, menu: Bounce Keyframes), not an ease — it generates decaying keys with amplitude and duration both scaled by decay, which is what separates gravity from a flutter.

The Smoother and The Wiggler ship (2026-09-01, keyframeAssistants.ts, Animation menu) — as dialogs with a live preview and one undo entry (2026-09-02), not the text prompts parsing "5, 25" they landed as. The Smoother replaces a dense baked track (motion sketch, tracking, audio keyframes, expression bakes) with the fewest keyframes that stay within a value-unit tolerance — Douglas-Peucker on VERTICAL deviation, because t and value have different units — then smooths the survivors' tangents. The Wiggler bakes a deterministic, seeded wobble into animated position as editable keyframes (x and y get independent seeds so the wobble is 2D, the same lesson wiggle() carries); the wiggle() expression remains the live alternative.

Roving is spatial (2026-09-01): on a position pair whose x/y tracks share a keyframe grid, Rove Across Time retimes both axes together by the 2D path's measured ARC length (applyRovingSpatial) — per-axis |value| roving is constant-speed only on an axis-aligned path, and tore the corner keyframe of an L-shaped move to two different times. Misaligned grids fall back per-track.

There is one graph editor (2026-09-02). The timeline's graph and the Motion panel's private editor were two implementations of the same idea that disagreed about easing vocabulary; the private one is deleted and both surfaces now host the same component (Timeline/GraphEditor.tsx, embedded by MotionEditorPanel.tsx). It scopes to the timeline's property search and adds Animated / Selected visibility modes, a frozen reference curve to bend a new track against, the easing-kind selector, rove, ease copy/paste, and an ease library popover of saved curves (customEaseStore.ts, EaseLibrarySection.tsx). The vocabulary itself is reconciled in one tested helper (easingVocabulary.ts), so "ease out" means the same thing on the pills, in the graph and in the library.

Keyframe editing gained the AE verbs (2026-09-01/02): the keyframe context menu carries an Interpolation submenu, Keyframe Velocity… and previous / next keyframe; onion skin has a settings popover for before / after / step / opacity; and the expression editor autocompletes at the caret — ranked, member-aware, Ctrl+Space — with the 48-chip reference strip collapsed behind a disclosure instead of occupying the panel.

Stagger, modifiers and drivers#

Parametric stagger (choreography.ts, planStagger.ts, ChoreographySection.tsx) animates a SELECTION rather than a layer: order modes, a base offset and swing in frames, a feel per gesture, a seed, and per-layer overrides. Evenly-spaced stagger is a metronome, which is why the gaps are non-uniform by design. The choice is stored as a record, so Re-apply replaces the previous choreography in one undo entry rather than layering a second one on top of it. What lands is ordinary keyframes on real properties — a head start on manual work, not a mode you get stuck in.

Modifier stacks (modifierStack.tsmodifierCompile.ts, ModifierStackSection.tsx) are an ordered, editable pipeline per animatable property: offset / multiply / clamp / wiggle / smooth / spring / loop / delay / audio / oscillate / expression rows, each with parameters and an enable switch, compiled to ONE expression attached to the property. The render path, the exporter, undo and the render cache see an ordinary expression and cost nothing new; the user sees sliders instead of somebody else's wiggle(0.35, …). Base-relative intrinsics compile as DISPLACEMENTS, so a wiggle in the middle of a stack does not discard the rows above it. Two behaviour presets ship as recipes.

Audio-reactive drivers (audioDriver.ts, AudioDriverSection.tsx) say "follow the music" as a control: band, attack/release ballistics, gate, output range and curve. Where the engine can express the driver it becomes an expression; where it cannot — the audio identifier is bound to the LIVE broadband playback meter, which cannot answer for one band at an arbitrary time — it bakes per frame, with the parameters kept on __audioDriver so Re-bake exists. A baked track with no record of where it came from is indistinguishable from hand-drawn keyframes, which is the whole reason the record is written.

Bake dynamics (bakeDynamics.ts, BakeDialog.tsx). Rigid bodies and particles are a live solve — buildSnapshot asks the solver for a pose every frame and the answer is never written down — which is why a simulation is the one thing in this editor you cannot art-direct. Baking runs it through the SAME solver the renderer uses, writes what it did as keyframes (particles to layers, bodies to transform tracks) and disables the live sim in the same undo entry.

The timeline as an editor#

Edit tools are modes, not modifiers (2026-09-02, timelineEditMode.ts, TimelineTools.tsx): Selection, Razor, Slip, Slide and Roll, each a lit button with a chord — Shift+S / Shift+C / Shift+Y / Shift+U / Shift+R, Escape returns to Selection — its own cursor, and a pointer HUD in frames. Slip and slide already worked as Alt-drag and Alt+Shift-drag and were invisible; roll — the two-sided trim at a cut, bounded by both clips' source handles, the one edit the older tools could not fake — did not exist at all. Razor draws a snapped cut line and Shift+click cuts every track. The Alt-drag gestures survive. This is its own store rather than an addition to the canvas Tool union, deliberately: these gestures act on time, not on the viewport, and folding them in would make every activeTool === 'select' check in the viewport wrong.

Clip edges snap (2026-09-01) to other clips, the playhead, markers, the work area and the comp bounds, with a guide line; the frame grid stays the fallback. Fit Composition (;) and Fit Work Area (Alt+;) sit in the zoom control and the View menu. Fit Selection (Shift+;, fitSelection.ts) joins them: keyframes win over clips when both are selected, and a selection at one instant gets half a second either side rather than an infinite zoom.

Snapping is a switch, not only a held key (2026-09-04, snapCommands.ts): a magnet button in TimelineTools, S with the timeline focused (the root claims the chord via data-shortcut-claim, so the global S — reveal Scale — survives everywhere else), persisted as timelineSnap. Alt still inverts it for one drag in both directions.

The playhead can pull the lanes along (playheadFollow.ts): off, page (jump one screen when it leaves the view — After Effects) or continuous (park it a third of the way across), persisted as timelineFollowMode; and a drag that comes within 24px of a lane edge auto-scrolls, ramping from a creep to 20px per frame at the edge itself.

Keyboard, not only pointer: the layer rows are a role="listbox" with a roving tabindex — one tab stop for the list, //Home/End to move, Shift to extend, Enter for the disclosure, Space for visibility. Selected keyframes nudge with / (one frame; Shift ten) and Alt+↑/Alt+↓ (value), and a 300ms burst is one undo entry (keyframeNudge.ts) — holding an arrow for a second must cost one Ctrl+Z, not thirty. Alt+click a disclosure twirls a layer and everything under it; Ctrl+** / **Ctrl+Shift+ collapse and expand every layer (expandCollapse.ts).

The time navigator is a window, not a fill (timeNavigator.ts): the box over the comp shows what the lanes show — drag its body to pan, its ends to zoom, double-click to fit the comp, click outside it to seek. The timecode readout is a field: click it and type 1:04, 320f, 2.5s or +10 (goToTime.ts), in place, instead of the modal that used to take over the panel.

Optional In / Out / Duration columns (timelineColumns.ts) are per-user (timelineExtraColumns) and off by default; widths live in one place so TL_COLUMN_WIDTHS, --tl-col-extra and headerWidthFor cannot drift. There is no Stretch column: nothing in the engine exposes a per-layer time stretch to drive it, and a column that shows 100% and refuses every edit is worse than none.

Per-cut transitions (core/timeline/transitions.ts, transitionStore.ts, Timeline/transitionPalette.tsx): cross dissolve, dip to black, dip to white and wipe, each held as a record — which cut, which kind, how long, how it sits on the cut — that MATERIALISES into overlapping bars, opacity ramps or a keyframed wipe, and dematerialises back to an exact snapshot of what was there before. The crossfade writer has existed since Sequence Layers, and it does the job perfectly once: what it leaves behind is four keyframes and two bars that happen to overlap, with nothing saying those facts are ONE thing. The record is the missing noun, and it is the authority — deriving a transition the other way round (by scanning opacity tracks for something ramp-shaped) would misread a hand-authored fade and delete it. Drag a chip onto a cut, double-click a cut, or use the clip menu; grips resize it live, and a change is dematerialise-then-materialise in one undo entry.

Assemble from Footage (assembleFromFootage.ts, AssembleDialog.tsx) turns a rush into a cut in one gesture and one undo entry: detect the cuts through Scene Edit Detection, split, drop the shortest shots, sequence the rest with dissolves. Every piece existed already and none of them were joined up, so an assembly used to cost four gestures — two of them per shot — and forty presses of Ctrl+Z to back out of a bad detection. New Composition from Selected Clips (compFromClips.ts) lifts a selection into its own comp. Use Proxies is a comp-wide View toggle and a Preview-menu checkbox.

Reviewing footage: source monitor, scopes, transcript#

Source Monitor (SourceMonitorPanel.tsx, sourceMonitorOps.ts): mark in and out in SOURCE seconds, shuttle with J / K / L (1× / 2× / 4× ramps), step by frame, then Insert, Overwrite, Add to end or New comp from range. The source-seconds → target-comp-frames conversion happens in exactly one place because it needs both halves at once — the range, and the fps of the comp the footage is landing in; a monitor that stored frames would be wrong the moment the same clip went into a 24 fps and a 30 fps comp. The range is expressed as the timeline's own trims (trimClipTo('end'), then 'start', then setClipStart), not as a bespoke insert-with-duration. The footage preview dialog can hand a clip straight to it.

Scopes (Scopes/ScopesPanel.tsx, core/video/scopes.ts): waveform (luma or RGB), RGB parade, vectorscope with 75 % targets, and histogram. Each accumulates the frame into a small integer histogram FIRST, so the painter's cost depends on the size of the panel rather than on the two million pixels of a 1080p frame, and a trace's brightness reads as density because density is a count. Input is point-sampled on a stride — averaging neighbours would INVENT code values that are not in the frame, which is the one thing a scope must not do. Fed from the RAM preview cache with the comp rect reconstructed out of the viewport, or from a render-loop tap (frameTap.ts): a synchronous drawImage + getImageData inside the draw tick, because the viewport canvas has no preserveDrawingBuffer and its pixels are gone by the time any timer fires.

Transcript panel, with text-based editing (Transcript/TranscriptPanel.tsx, transcriptOps.ts, core/captions/transcriptEdit.ts): transcribe the comp, click a word to seek, select a run of words and delete its time range from every layer at once. That is not the ripple delete applied per layer — several layers are cut at the same two instants and the gap that closes is the range's length ONCE, not the sum of the clips inside it, which for a video plus its audio would be exactly twice too far. Ranges apply last-first, so earlier boundaries stay stated in the current time base. Plus a filler-word finder, transcript → captions, and SRT/VTT export.

Silence removal and ducking (silenceRemoval.ts, ducking.ts, with their own dialogs). Detection is PURE — samples in, source-second ranges out — so the dialog's "will remove N gaps totalling S s" is computed by the same code that makes the cut, not by a cheaper estimate that agrees on the easy cases. Removal splits picture and sound at the same boundaries, drops the pieces and closes the gaps, in one undo entry. Ducking holds a music layer a set number of dB under a voice layer as level keyframes rather than a sidechain compressor: Web Audio has no sidechain input, a live detector would need an AudioWorklet plus a second implementation for the offline export mixdown, and the two would not agree. Baked keyframes are the same numbers in preview, in export and on screen, and you can drag one afterwards — a compressor you cannot see is a compressor you cannot fix. The parameters live on __ducking, so Re-duck exists for when the voice track changes.

Chapters from markers: labelled composition markers become real chapters on MP4/MOV export, through an ffmetadata sidecar and -map_chapters.

Compositing#

38 blend modes on one GPU shader path (BLEND_COMBINE), including the four Stencil/Silhouette modes and Dissolve / Dancing Dissolve. Bezier masks with all 7 AE modes (none included), effect-scoped masking, and protected time regions. Track mattes (alpha/luma ± invert), decoupled from stacking order. Precomps with nesting and continuous rasterization (continuousRaster.tsbuildSnapshotMotionRendererBackendAppTextureProvider, control in PrecompControl.tsx).

Motion blur#

Shutter angle, shutter phase, and adaptive sampling — all three. Camera moves blur too (2026-09-01): an animated active camera extends the motion gate to every 3D layer and each sub-frame sample projects through the camera's pose at that sample's comp time — a static card under a keyframed pan blurs like a moving card under a static camera. The adaptive sample count for 3D layers is sized by PROJECTED travel, so a card flip or a depth push no longer samples at the static-layer floor.

Shapes#

Nine chainable path operators: zigzag, roundCorners, pucker, twist, offset, roughen, trim, repeater, wiggleTransform. The chain reorders, and the schema-1.3.0 migration re-keys keyframe tracks onto the new operator ids. AE permits one trim and one repeater per shape; so does this (pathOps.ts resolves each with find), which is parity rather than a limit.

The chain's currency is a list of PolyRuns, not one polyline — that is what lets trim live in it, since trimming produces multiple open arcs.

Knife tool (K, KnifeTool in packages/workspace/src/tools/builtin.ts, cutting through core/geometry/pathCut.ts): drag a line across the canvas and every targeted shape path is cut EXACTLY along it, cubic segments included, with closed shapes capped into islands. The tool's own decisions are the interesting half — a tap below the drag threshold must not cut (the line's direction would be pointer noise and the submitted line is infinite), a selection wins so "cut these two" is expressible, and with nothing selected only layers the line actually passes THROUGH are cut, by their corners rather than their AABB, so a diagonal near-miss stays a miss. A Pathfinder section gives shape layers the boolean set operations directly.

On-canvas gradient editor (gradientHandles.ts + GradientHandleOverlay.tsx, per-fill chip): axis grips, stop diamonds, add / duplicate / delete stops, and a colour picker on double-click. The arithmetic half — an axis, a projection onto it, four kinds of hit test — is a pure, unit-tested module, the same split focusPlane.ts and core/effects/effectHandles.ts already make.

Text#

Full animator selector stack, multiple selectors, wiggly selector, per-character 3D, rich text, paragraph text, text-on-path. textPath is correctly not a path operator: it consumes a mask and emits glyph placement, so it neither accepts nor produces the chain's currency. AE models this the same way (Text → Path Options).

3D#

Classic 3D: cameras, 5 light types (point/ambient/spot/parallel/environment) with AE falloff curves and cone feather, extrusion with bevels, face materials, ortho views, quad view. See §4 for what "3D" does not mean here.

Environment light (2026-09-01, core/scene/environmentLight.ts): an image-based sky — a preset equirect (studio / day sky / sunset) projected onto band-2 spherical harmonics, expanded at snapshot time into a derived rig (one ambient irradiance floor + up to six axis-deviation parallels) that rides the EXISTING 8-slot light array. Zero renderer changes; works under Phong and PBR; envRotation is keyframeable (an animated sky). It lights, it never glows — no wash layer. AE has no equivalent of any kind.

Any image can be the sky, and it now reflects too (2026-09-02). The sky is no longer only a preset: any image or EXR asset projects to SH through core/scene/environmentImage.ts (EXR keeps its linear energy), cached per asset, with the first frame that asks kicking a decode, falling back to the default preset for that frame and repainting when the projection lands. Beside the irradiance probe the same sky builds a prefiltered specular atlas — importance-sampled GGX, one level per roughness — and the shader does split-sum IBL against an analytic env-BRDF in both dialects (WGSL and GLSL). A Reflections row on the environment light turns it on; Physical and Phong reflect, Toon deliberately does not. The whole path is gated so every scene authored before it renders byte-identically (environmentReflections.test.ts, plus the env-reflect-metal / env-reflect-metal-off render-test pair).

Composition Settings ▸ World (CompositionSettingsDialog.tsx, round-trip pinned by core/api/worldSettings.test.ts): a default environment for new lights, a ground level for the grid, and a sky backdrop stored for later. All three ride the existing comps chunk and are optional and absent until set, so a document written before they existed comes back without them rather than defaulted-and-rewritten.

Material section (core/scene/material.ts, MaterialSection.tsx, materialStore.ts): every reflectance parameter in one place with a live shading preview, per-face overrides for extrusions, and a persisted material library seeded from the built-in presets. Metal stays visible under Phong rather than going black. Light presets (Key, Fill, Rim, Soft top, Warm practical, Cool moonlight, Sunset key) and a Kelvin colour-temperature row mirror the camera's lens presets — colorTemperature.ts carries the blackbody fit and its inverse. Bevel Style (angular / concave / convex) has a dropdown; its setter had no caller before 2026-09-01.

Real curved primitives (2026-09-02, core/geometry/primitiveMesh.ts, core/scene/primitiveLayer.ts): sphere, cylinder, cone, torus, capsule and box are now MESH layers with editable segment counts, created through the same imported-model path, with smooth per-vertex normals so a tessellated curve lights as a curve instead of as its facets. A sphere is not an extruded circle (that is a capsule) and a torus has a hole through an axis the extrusion sweep does not have, which is why these are surfaces of revolution written directly rather than outlines swept along z. Cube and plane keep their bevel-capable extruded forms.

glTF PBR maps and external .gltf (2026-09-02, core/media/gltf.ts): normal, metallic-roughness, occlusion and emissive maps land on a separate mesh3d-pbr material, with texture transforms baked into UVs and tangents taken from derivatives. An external .gltf now imports with its sidecar files, and refuses by NAMING the ones it could not find rather than importing a hole. File ▸ Import 3D Model is the explicit entry point beside the drop target.

The 3D gizmo and the DOF focus plane work in every pane (2026-09-01/02) — 2-up and 4-up secondary views are no longer view-only. The focus plane is drawn in the viewport at focus distance with in-focus bands and a centre handle that pulls focus along the view axis, writing through the same keyframed path the inspector uses; it toggles from the 3D view menu.

glTF model import ships (2026-09-01, core/media/gltf.ts + core/scene/modelImport.ts): drop a .glb (or embedded-URI .gltf) into the Assets import and it becomes ORDINARY layers — a 3D null per glTF node, a mesh layer per primitive — rendered through the same depth-grouped extrudedMesh path as extrusions, so imported models depth-sort, light per-fragment (Phong or PBR via Material Options) and keyframe with the standard gizmo. The source .glb persists as a data: URL inside the scene document (every edition's save path carries it; re-parsed on open by modelHydrate), which is why imports above 20 MB warn about document weight. A .gltf referencing external files is refused with "export as .glb".

Animation clips bake to real keyframes (core/scene/modelAnimation.ts): the file's first clip lands on the node layers' own x/y/z / rotation / scale tracks at import — visible in the timeline, editable in the graph editor, retimable with speed ramps. Sparse rotation spans densify along the slerp arc (~15 samples/s) and every baked euler unwraps toward its predecessor so a spin never snaps back 360° at the ±180° seam. Extra clips are reported in the import toast, not silently dropped.

Skinned meshes deform live (core/scene/modelSkinning.ts): JOINTS_0 / WEIGHTS_0 primitives CPU-skin at snapshot time against their joint layers' CURRENT world matrices — the joints are ordinary imported nulls, so a baked walk cycle, a gizmo drag on a bone, or hand-set keyframes all deform the mesh identically. Skinned poses upload under pose-hashed GPU buffer keys (identical poses — a paused playhead, a looping cycle — reuse one buffer). A deleted joint layer falls back to the rigid bind pose rather than half-deforming.

Morph targets blend live (core/scene/modelMorph.ts): a primitive's POSITION/NORMAL target deltas blend on the mesh layer's animatable morph0…morphN-1 props (a file's baked 'weights' clip, keyframed sliders and the graph editor all drive the same numbers), then feed the skinning pass — the glTF order, so a face morphs AND rides its skeleton. Blends upload under weight-hashed buffer keys like skinned poses.

Toon (cel) shading is a third reflectance model beside Phong and PBR (Material Options → Shading → Toon): the same per-fragment lighting quantized into 2–8 hard bands (Bands slider), with a fixed tight specular blob. It rides the existing lit-flag/shininess uniform slots — zero layout changes — and applies to anything on the depth-tested lit path, imported models included. AE has no 3D cel shading at all.

3D IK on any parented 3D layers (core/scene/boneIK3d.ts): a damped CCD solver over a chain of 3D nulls — exactly what a glTF skeleton imports as. Two palette commands: Pose 3D IK Chain at Target (one-shot, at the playhead) and Bake 3D IK to Target (solve every frame against the target layer's ANIMATED position and land real rotation keyframes on the joints — animate one null, bake, the limb follows; the result is ordinary keyframes). Select the chain tip, then Ctrl/Cmd-click the target. The effector keeps its own rotation, so FK on the wrist survives IK on the arm.

Rigging#

Bone skeleton with FK, IK and FABRIK, weight painting, vertex weight editing, plus an ARAP puppet with pins and sketch. Both compose on the same layer and are GPU-deformed. AE has no skeleton at all — its users buy DUIK.

Particles#

Default simMode: 'ballistic' — closed-form emitter (particleSim.ts): particle i born at i / birthRate, hash RNG, ballistic p0 + v0·age + ½g·age². Opt-in simMode: 'stateful' (statefulParticleSim.ts + SimulationCache) adds frame-stepping with floor bounce and seeded-replay scrub. Still no turbulence, particle–particle collisions, trails, sub-emitters, 3D, or layer-as-particle.

Composition background and export alpha#

Composition background and pasteboard are separate at every layer: store (background + transparent), transport (snapshotToFrameScene), render (BackgroundPass, clipped to the comp rect) and UI. A transparent comp is a real hole in the canvas; the viewport shows a checkerboard clipped to the comp rect behind it (Workspace.module.css .transparencyGrid — a DOM element, so no render-path or export involvement). The background ColorPicker exposes alpha, so partial alpha is user-reachable, not only expressible in the model.

What actually reaches each export format — verified against the encoder args, because the dialog previously claimed all of them kept alpha:

Format Alpha How
mov ProRes 4444 only, yuva444p10le (the 422/HQ/LT/Proxy profiles are yuv422p10le — no alpha plane)
webm VP9 yuva420p + -auto-alt-ref 0, PNG staging
png, png-sequence staged as PNG
mp4 libx264 yuv420p — flattened over black
gif palettegen/paletteuse requests no transparency (the format has 1-bit transparency; the graph does not ask)
jpg-sequence JPEG has none
json, lottie n/a carry no comp background at all

Import / export#

Lottie import and export, SVG import including SMIL and CSS animation, image sequences, video with audio. The rendered formats are mp4, webm, gif, mov, png, png-sequence, jpg-sequence, exr-sequence, wav, json and lottie, plus the hdr10 / hlg delivery variants and the interchange writers (edl, otio, fcpxml, ale, mogrt) — 18 export formats in §1's count, which unions VideoFormat with ExportFormat. mp4/mov need the desktop app (ffmpeg); the browser gets WebM or a PNG sequence.

The render queue pauses and resumes (2026-09-02, renderQueueStore.ts, renderQueuePauseResume.test.ts). The desktop sink already staged every frame as an image in a per-job temp dir and encoded once at the end from frame_%04d, so a paused render is nothing more exotic than "the loop stopped after frame N and the files for 0..N are still there". Pause (one job) and Stop (the whole queue) are therefore the same mechanism at two scopes: both keep the sink and both carry a resumeFrame, and a resumed job keeps the progress it had rather than restarting at zero. Losing the work is its own verb — Discard — and it is the only destructive one. Half-rendered jobs are picked up ahead of queued ones. The live handle is an open sink and is never serialized, so this is session-scoped: quitting abandons a paused job's staging directory.

Chapters ride MP4/MOV export as described under the transcript section above.

Templates#

Exposed fields (templateFields.ts), 5 field kinds (text, color, number, image, media), media slots (mediaSlots.ts), responsive time and protected time regions (responsiveTime.tsTimelineControllerResponsiveTimeSection.tsx). 12 test files.

There is no data binding. dataBinding, dataSource and csvBind have zero hits repo-wide. It was listed here as shipped; it does not exist, and building it is greenfield work rather than a remainder.

The shell#

Smart guides (packages/workspace/src/snap/smartGuides.ts + SmartGuideOverlay.tsx) are the measuring half of snapping. SnapEngine has always answered "where does this edge want to land" — alignment, a pink line with no number attached. This adds the other half: distance badges, equal-spacing and equal-size detection with snapping, and Alt-hover measuring, behind a View Options toggle. Turning it off takes away the measuring, not the magnet.

Preview controls have one home (2026-09-02): resolution, adaptive floor, motion blur, draft, onion skin, region of interest and the cache actions all live in the Preview menu, with Cache Work Area Now, Purge RAM Preview and Purge Disk Cache as real commands (previewCacheCommands.ts). Window ▸ Workspace lists the saved layouts with the active one checked, plus Save Layout as… and Reset (workspaceMenu.ts). The menubar renderer draws nested submenus through one shared component, which is what let Scene Edit Detection, live boolean path ops, path bakes and the 3D primitives become Layer-menu submenus as well as palette commands.

Project swatches (SwatchesPanel.tsx): a palette persisted in the document plus a Document colours list derived on demand, offered both inside the colour picker and as a panel with apply-to-selection.

An interactive onboarding tour (onboardingStore.ts, OnboardingOverlay.tsx) replaced five paragraphs of prose in a centred card — the one thing a first-run tour must not be. A step is a POINTER (a CSS selector for the real control) plus, optionally, a TASK: it advances when the action is actually done, checked against a baseline captured when the step began, rather than on a Next button pressed by someone who never looked.

AI#

65 typed tools over a deterministic caster and a hand-authored technique library, with a validator that assumes the model lies, a self-critique pass using rendered-frame evidence, and a hard one prompt = one undo entry contract. Server edition onlyaiEnabled() is isServerEdition(), and the panel, renderers, settings tab and Electron IPC registration are each gated independently (pinned by editionAiSurface.test.ts).

Plugins#

Worker sandbox with fetch/localStorage/DOM removed, permissions shown before any code is downloaded, signed packages, heartbeat termination, declared-host network access proxied through the main process, and API 4 shader effects that can draw pixels. Every plugin mutation is one undo entry. This is the most actively developed area of the repo — 211 of the last 211 commits touch it.


4. The honest gap against After Effects#

The question this section answers: can a user build a complex, high-design motion video here? For 2D motion design — kinetic typography, logo stings, product and UI motion, shape animation, rigged 2D character work — yes. The core compositing and animation model is at genuine parity.

The gaps are not in the engine. They are in the layer above it.

Tier 1 — categorical exclusions#

Motion tracking & footage repair (shipped column). Point / planar / mask tracking, Smooth Stabilize, subspace / rolling-shutter mesh footholds, and a planar 3D camera solve live under src/core/tracking/. Still open vs AE: full multi-plane SfM, Roto Brush–class AI mattes, Content-Aware Fill.

Re-verified 2026-08-21: the old “zero tracker” claim is obsolete. Remaining gaps are depth (SfM, AI roto, subspace quality), not absence. Keying (keylight, linear-color-key, simple-choker, set-matte, shift-channels) and tracked corner-pin / mesh warp also ship.

The RENDERER'S footage decode path is an HTMLVideoElement; the real decoder now exists beside it (corrected 2026-08-19). The subsystem this paragraph used to say was missing shipped: src/core/video/ demuxes MP4s with mp4box (pure JS — demux and GOP/B-frame index are jest-pinned against real ffmpeg fixtures), and ExactVideoSource drives a WebCodecs VideoDecoder for true random access on exact frame boundaries. Its first consumer is the footage preview's Frame-by-frame mode. The RENDER path still seeks an element (seek → onseeked → repaint, approximate boundaries) on purpose, until the exact path survives a real-machine visual pass — so the ceiling on tracking/rotoscoping is now the renderer INTEGRATION, no longer the missing subsystem.

Read the next two paragraphs before repeating the older version of this claim. Proxies and footage interpretation both exist, and the frame-rate limit this section used to assert has been fixed — see §5's 2026-08-11 row.

src/core/assets/proxy.ts is a measured proxy system (seek is 97.6 % of the cost at 4K; proxies win on resolution and GOP length, hence -g 12), and its export invariant is enforced by polarity rather than vigilance: useProxies defaults absent/false and only the interactive viewport ever sets it true, so export, the offline renderer and the render-test harness cannot opt in by forgetting. Proven against encoded output in proxyExport.test.ts.

src/core/source/sourceInfo.ts holds a real FootageInterpretation, stored per-asset rather than per-layer: conformFps, alpha (premultiplied interpretation, read by the renderer), loopCount, PAR. A proxy substitutes pixels only — every timing and geometry fact keeps reading the original asset's metadata and interpret through sourceOf, so a proxy cannot drift out of alignment with its source by construction.

3:2 pulldown is handled end to end: pulldownDetect.ts finds the phase-locked field-repeat cadence (Interpret Footage ▸ Detect), and Remove Pulldown (interpret.pulldownPhase) makes the exact decode path serve inverse-telecined progressive film frames — pulldownFrameFor remaps frame indices, re-weaving the one film frame per cycle that exists only as fields split across two video frames (ExactVideoFrameCache.weaveCanvas). Legacy fallback paths bob instead.

What genuinely remains missing from this column: a placeholder/offline-media workflow.

Depth of field is per-LAYER, not per-scene. The renderer does own two DOF shaders — coc-blur, whose radius is interpolated per pixel across a quad from four corner CoC radii (planDofCocCorners in dofStrips.ts), and bokeh, a polygonal iris gather — both dispatched by CompositionPass. So a tilted card gets a real blur gradient and an iris shape. What is still absent is the thing that would make it cinematic: there is no sampleable depth buffer feeding a cross-layer gather, so blur is computed from each layer's own geometry and one layer cannot blur ACROSS the silhouette of another. Foreground bokeh does not bleed over a background, and a partially-occluded highlight cuts off at the occluder's edge.

Corrected 2026-08-12: extruded faces already carried per-face depth/CoC. Corrected 2026-08-14: flat depth-spanning quads use strip subdivision (max 8). Corrected 2026-09-01: strip subdivision is the FALLBACK; corner CoC + bokeh are the mechanism, and "no DOF code in packages/renderer" is retired (§5).

Still missing vs AE: diffraction fringe and highlight gain, and the depth-buffer gather above — which is the prerequisite the other two want, not a polish item.

Lighting is per-fragment on the depth path, per-quad only as a fallback. This entry previously claimed the opposite. The depth-tested 3D path runs real per-fragment Lambert plus Blinn-Phong specular in the shader (builtin.ts fn shade3d), using a world-position varying (o.world = obj.model * vec4(pos,0,1)); shadeLayer's per-quad RGB multiplier is the quadGain fallback for branches that cannot shade per-fragment (FrameScene.ts — matte, adjustment, precomp, advanced blend, glass, motion blur, deformed mesh). Extruded geometry is shaded per face, each face being its own renderable with its own normal.

What remains true: 2D layers receive no Lambert shading at all (buildSnapshot.ts gates it on is3D), non-depth-eligible layers still fall back to per-quad, and shadows are 2.5D projections rather than geometry-aware cast shadows.

Sharpened 2026-08-11 — "per-fragment" is doing less work than it sounds like. fn shade3d opens with let N = normalize(obj.model[2].xyz): the normal is the renderable's Z axis, constant across the whole surface. What varies per pixel is the light vector, distance attenuation and falloff, via the world-position varying.

Superseded 2026-09-02, in two halves. Surface detail exists on imported meshes: glTF normal / metallic-roughness / occlusion / emissive maps ship on a mesh3d-pbr material with tangents from derivatives, and real curved primitives carry smooth per-vertex normals. Image-based lighting exists, both halves — environmentLight.ts for irradiance and a prefiltered specular atlas with split-sum IBL for reflections. What has NOT changed is the ordinary 2D/extruded layer: its normal is still the renderable's Z axis, constant across the surface, so a solid or a text extrusion has no surface detail of its own, and extrusion is still shaded per face, each face flat. shadowCatcher remains zero hits — there is still no way to land a 3D element on a real plate.

Particles — stateful floor bounce shipped (2026-08-14); density still limited. Ballistic mode remains the default. Stateful mode (simMode: 'stateful') uses SimulationCache for seeded-replay scrub with a floor bounce — the cheapest history-dependent proof. Still no turbulence or wind field, no collisions between particles, no sub-emitters, no trails, no layer-as-particle, and no 3D particles. Particular / Form / Plexus class density remains a later ceiling.

Imported 3D models — this entry previously said out of scope by direction. Corrected 2026-09-02: the user reversed that, and it shipped. glTF .glb/ embedded .gltf import lands as ordinary layers — a 3D null per node, a mesh layer per primitive — through the same extrusion mesh render path every other 3D layer uses (modelImport.ts). Four mechanisms build on that: CPU skinning against joint layers (modelSkinning.ts), morph-target blend shapes on the mesh layer's Transform (modelMorph.ts), glTF clips baked at import onto the node's own position/rotation/scale tracks (modelAnimation.ts), and 3D IK — CCD over a chain of parented 3D nulls (boneIK3d.ts), composing with skinning for free. Environment Light (environmentLight.ts) is a separate mechanism: an SH irradiance probe (procedural sky presets, or any image, projected to 9 coefficients) expressed through the existing 8-slot light array as a derived ambient + up to six parallel lights — zero renderer changes.

All four items this paragraph listed as out of scope closed on 2026-09-02. HDRI / image skies import (any image or EXR asset, environmentImage.ts); reflections ship as a prefiltered specular atlas with split-sum IBL and an analytic env-BRDF in both shader dialects, behind a Reflections row on the environment light; PBR texture maps — normal, metallic-roughness, occlusion and emissive — land on a mesh3d-pbr material; and an external-file .gltf imports with its sidecars, refusing by naming the ones it cannot find. Real curved primitives (sphere / cylinder / cone / torus / capsule / box) came with them. Shadow maps shipped later the same day: an opt-in per-light shadowMap (512 / 1024 / 2048, bias, softness) renders a depth-eligible run's casters from the light into a packed linear-distance target (rendergraph/passes/shadowMap.ts) and samples it with 3×3 PCF inside shade3d at bindings 9/10, multiplying that light's diffuse and specular for receivers that accept shadows; the light's 2.5D projected copy is suppressed so nothing doubles. Off by default, so every earlier scene is byte-identical (shadow-map-spot / -off are the witness pair). Limits, stated in code: one mapped light per run, and a point light uses the spot frustum along its aim. SSAO is not built, and the reason is structural: every depth-eligible run draws into a multisampled target, and neither backend can sample a multisampled depth — the route is a linear-depth prepass bound before the run draws (ambient-only AO cannot be a post-pass once ambient and direct are summed). Height displacement is not started. A shadow catcher already exists as Accepts Shadows ▸ Only.

Linear working space — storage slice shipped (2026-08-14). Float precision (rgba16float intermediates) already existed; grade / blend / blur maths run in linear light via LINEAR_WORKING_SPACE (default on) in packages/renderer/src/shaders/linearWorkingSpace.ts. LINEAR_INTERMEDIATE_STORAGE is also on: RTs stay linear and EffectPass scene-blit encodes to sRGB for the canvas. Uploads carry a displayReferred tag (TextureDescriptor) and use rgba8unorm-srgb when HARDWARE_SRGB_UPLOADS is on (default off — premultiplied bytes need shader linearize-after-unpremul; see linearWorkingSpace.ts). TEXTURED draws skip redundant decode only when that flag is on. RT copies use the *-linear variant. Every frame routes through scene-color + scene-blit so linearized solids are encoded, not written into the 8-bit canvas. Kill switches sit next to HDR_INTERMEDIATES in RenderGraph.ts. Project colour settings shipped (2026-08-14): Composition Settings → Color tab persists workingSpace (srgb-linear | aces-cg), displayTransform (srgb | aces ODT), and bitDepth (16 | 32 float) via colorManagementStore → document round-trip. MotionRendererBackend calls setActiveColorPipeline each frame; scene-blit uses workingToDisplay; float RTs pick rgba16float / rgba32float via intermediateFloatFormat. Still absent: HDR output.

Tier 2 — ceilings on visual density#

Effect breadth: 183 effects vs AE's 400+. The raw count misleads in both directions — nobody uses 400, and the 183 effects present are properly parameterised (Levels, Curves, Channel Mixer, Keylight with despill/choke/softness). What matters is the missing classes, not the delta: no 3D Stroke, no Form/Plexus, no Element 3D. The dense, expensive-looking AE frame is usually five to eight stacked third-party effects, and that stack has no equivalent here.

Corrected 2026-08-12, twice over. The number said 73 — the count when the sentence was written, left behind by registry growth, and the value every brief written against this document inherited. And the missing classes named "no volumetric light rays (Shine)" and "no optical-flare system worth the name": light-rays, lens-flare, light-sweep and beam all ship, each with a registry def, a Canvas2D reference, a Generate entry, and (as of 2026-08-14) a GPU shader. The count is now phrased as "183 effects" rather than as a bare figure specifically so that docPropagatedCounts.test.ts can check it.

Variable-width mask feather LANDED (2026-08-20). MaskPoint gained an optional per-vertex feather diameter; any vertex carrying one routes the whole path through the distance-field renderer (maskFeather.ts: hard coverage → 3-4 chamfer signed distance → nearest-outline-sample width, grid-bucketed → smoothstep ramp straddling the edge), with the width interpolating along the outline between vertices. Absent overrides mean the uniform blur renderer, byte-identical to before. The width rides mask animation like every other vertex quantity, and the matte cache signature digests it (the parity guard caught that within minutes of the field existing — see maskSignatureParity.test.ts, doing exactly the job it was built for).

Wiggle Transform LANDED (2026-08-20, wiggleTransform in pathOps.ts): chain-level like Trim and the Repeater, one temporal-noise affine transform per run — so Repeater → Wiggle Transform makes every copy wander independently, and correlation dials the swarm back into one body. Landing it also fixed a real bug: resolveOne never carried correlation through resolvePathOps, so Wiggle Paths' Correlation control worked in unit tests (which call roughen() directly) and did nothing in the render path. Wiggle Paths, listed here earlier as missing, DOES exist — stored as roughen, which is why a grep for wigglePath found nothing. See §5's correction; it gained the Correlation parameter it had been missing.

Pixel Motion LANDED (2026-08-20). frameBlend now carries a mode: mix keeps the two-quad cross-dissolve, pixelMotion renders ONE quad sampling a motion-compensated in-between (rendering/pixelMotionFlow.ts — deterministic block-matched flow at a downscaled raster, symmetric warp-blend at full res; rendering/pixelMotion.ts caches flow per frame PAIR, the expensive half). The dependency this entry predicted held: it is built directly on the exact decoder's frames (feedPixelMotion in MotionRendererBackend), degrading to nearest-frame while either bracket is still decoding — never a hole, never a half-warped guess — and to Frame Mix wherever flow reports no texture. Synthetic-frame suite proves recovery of known motion to sub-pixel, endpoint identity and determinism; a real-footage on-machine pass is still owed. GPU-accelerated (2026-09-01): estimation now prefers an integer WebGL2 twin of the CPU search (rendering/pixelMotionFlowGpu.ts — integer luma/SAD, fixed scan order, RGBA32UI readback, ~13× the CPU search at 1080p) that must prove itself BIT-EQUAL to the CPU path on a synthetic pair at init, so the backend choice can never make preview and export disagree; the parabola and smoothing stay on the CPU (finalizeFlow, shared by both backends). The full-res warp followed (2026-09-01, rendering/pixelMotionWarpGpu.ts, ~80× at 1080p): float bilinear can't be bit-equal, so its gate is the session decision itself — tolerance + determinism self-check at init, then preview and export both warp on whichever backend won for the whole session. The CPU warp remains the fallback (and lost ~30% of its own cost to a flow-bilinear unroll, pinned bit-identical by test). The old text stood here since 2026-08-11 saying Pixel Motion was the mode people actually reach for on retimed footage, and that the decoder problem in Tier 1.

Dissolve and Dancing Dissolve LANDED (2026-08-20, combine ids 35/36): the determinism contract this entry flagged as the real cost is met the way Roughen met it — an integer hash of (comp-grid pixel, seed) in both shader dialects, no clock in the shader. Plain Dissolve hashes with seed 0 so its speckle holds still; Dancing's seed is the comp frame index, computed by the adapter (FrameScene.dissolveFrame) from the same playhead export renders, so preview at any zoom and the export boil identically. That makes the mode table all 38 of AE's 38. Classic Color Burn/Dodge/Difference still render identically to their modern counterparts — kept for round-tripping and picker parity, and documented as such rather than silently wrong.

No 3D gizmo snapping — as a feature. The half-built switch this entry used to describe is gone: gizmo3dSnapping was deleted with nine sibling symbols and src/stores/__tests__/deadLayoutState.test.ts keeps it deleted.

Tier 3 — friction on long or complex projects#

  • Render-queue pause/resume shipped 2026-09-02, and this entry's diagnosis was right: the loop was always resumable (fixed timestep, index / fps, an existing startFrame/endFrame range, no accumulated state) and the only obstacle was that abort disposed the sink and took the ffmpeg staging directory with it. Now abort is treated as PAUSE — the run resolves with the next offset instead of throwing, and the sink is deliberately not disposed (renderQueueStore.ts). Pause (one job) and Stop (the queue) are one mechanism at two scopes, both carrying a resumeFrame; Discard is the separate destructive verb. The remaining limit is honest and narrow: the resume handle is a live open sink and is never serialized, so pause/resume is session-scoped — quitting the app abandons the staged frames.
  • The local project browser shipped 2026-08-20, and this entry's three blockers are all closed — corrected 2026-09-02, because the text below it was still asserting them. better-sqlite3 is in optionalDependencies; src/core/localIndex/indexWriter.ts is the writer the index never had (saves and opens write it, and a thumbnail worker fills the thumbHash column the schema always carried); the start screen is a card grid joined with the MRU for anything the index has not seen, degrading to the MRU list in a browser tab. What is still owed is a real-device pass: the driver is optional and native, so it wants an electron-rebuild against the Electron ABI, and until that runs index:available can still be false on a given machine and getLocalIndex() fall back to MemoryLocalIndex.
  • Essential Properties — promotion shipped 2026-08-14. Instance overrides (compInstanceOverrides.ts / CompOverridesSection) already existed for the numeric Transform set. Source comps can now publish properties via right-click → "Add to Essential Properties" (__essentialProps on the root). When anything is published, the instance panel lists only that curated set (including nested layers). Empty still falls back to every overridable prop on direct children. Non-numeric overrides shipped 2026-08-17: text, fill and color join the numeric set via OVERRIDE_PROP_KINDS. Colour was the awkward one — it is stored as a hex string but ANIMATED as three channels (fill_r/_g/_b), so an override has to suppress those too or a keyframed colour repaints over it every frame.
  • The frame cache has a disk tier as of 2026-08-17 (frameDiskCache.ts) — PNG blobs in a byte-budgeted LRU, read back ahead of the playhead, so a looped work area longer than the ~60 frames RAM holds stops re-rendering every pass. This entry said it was session-scoped and that surviving a restart needed a content-derived key first. Both halves landed the same week and this text was left behind — corrected 2026-09-02 against the file's own header. sceneContentHash.ts made the key name a scene rather than merely notice that one changed; then RETENTION in frameDiskCache.ts parks a generation that stops being live instead of deleting it, so an undo gets its frames back with zero re-renders, and a MANIFEST reconciled against the blobs actually present lets a restart come back warm. The honest limits are now different ones: the budget is global and evicts a parked generation wholesale, and a store without a manifest (anything but IndexedDB) still purges at open.
  • Output-module templates ship (2026-08-18, outputTemplates.ts): named render-settings bundles with built-ins, applied from the queue dialog. Resolution is stored as a SCALE — "Half Res" saved from an HD comp still means half on a 4K one — and duration is never stored, because how long a comp runs is a fact about the comp.
  • Pre-1.0, with breaking .motion format changes still expected.
  • No collaboration — removed by choice, but still a loss against Rive/Jitter.
  • No ecosystem: no plugin market with content in it, no template marketplace, no tutorials, no hiring pool. This compounds the effect-breadth gap, because complex AE work is normally assembled from acquired presets and templates.

Where it beats AE#

Skeleton rigging (AE has none), the AI layer, plugin security, one engine for preview and export, the open content-addressed .motion bundle format, built-in Lottie export, and price.

Highest-leverage work, if the goal is complex output#

  1. Linear-light colour — shipped 2026-08-14. Grade / blend / blur run in linear under LINEAR_WORKING_SPACE; RTs stay linear until scene-blit (LINEAR_INTERMEDIATE_STORAGE); uploads tagged display-referred (displayReferred + optional HARDWARE_SRGB_UPLOADS). Project settings (working space, ACES ODT, 16/32-bit intermediates) live in Composition Settings → Color. Remaining: HDR output.
  2. Particle system — stateful floor bounce shipped 2026-08-14. Opt-in simMode: 'stateful' with SimulationCache. Remaining density: turbulence, collisions, trails, sub-emitters, 3D / layer-as-particle.
  3. Depth of field — corner CoC + polygonal bokeh shipped 2026-09-01. coc-blur varies the blur radius per pixel across a quad from four corner radii (planDofCocCorners), bokeh gathers a polygonal iris, both in builtin.ts and dispatched by CompositionPass; strip subdivision is the fallback. Remaining, and now the only item: a sampleable depth buffer so the gather can cross layer silhouettes, plus highlight bloom.
  4. Essential Properties — promotion shipped 2026-08-14. Source comps publish props via the property menu; instances show the curated set (nested OK). Remaining: colour / text overrides beyond the numeric Transform set.
  5. Light/glow/flare — Beam / Sweep / Flare / Rays GPU-ported 2026-08-14. The procedural light family now runs as shaders with retained Canvas2D references. Remaining: broader glow polish (and any further generators).

Motion tracking is a much larger project serving a different audience and should not be sequenced against these. Note also that it is gated behind the decoder's renderer integration (Tier 1): the demuxer + WebCodecs subsystem exists as of 2026-08-19 (src/core/video/), but a tracker cannot be more frame-accurate than the frames the RENDER path feeds it, and that path is still the HTMLVideoElement until the exact path passes a visual check.


5. Corrections this rewrite made#

Recorded so the same claims are not reconstructed from git history and believed.

Previous claim Reality at 40ad98a
"38 effects" / "58 effects" (same file, two tables) 73
"Chainable path operators — trim and rep remain single-slot", recorded as a permanent decision Both are chain entries in PathOpType
"Continuous rasterization — the one remaining gap" Shipped, with a renderer read path and an inspector control
"62 AI tools" 65
"47 Zustand stores" 39
"Cameras / lights / shadows — parity" Shading is per-fragment on the depth path (see the 2026-08-10 row below); shadows really are 2.5D projections
Depth of field implied working Per-face / strip Gaussian CoC; no per-pixel DOF in the renderer

The pattern across all seven: a number or a status written once by hand, then never re-derived. §0 exists to stop the next one.

Corrected 2026-08-10, by a verification pass over this document#

The five below were found by checking this file's own prose against the code. Four were wrong. Two of the four were inherited verbatim from the deleted predecessor and restated here without being checked — so the rewrite reduced the propagation problem without ending it, which is why §0 now says plainly that only the §1 counts are under test.

This document said Reality at 7e59fd0
§4 "Lighting is a flat per-quad multiplier… no gradient across a large layer" Backwards. Per-fragment Lambert + Blinn-Phong ship on the depth-tested path (builtin.ts fn shade3d, world-position varying); per-quad quadGain is the documented fallback (FrameScene.ts). Extrusion is shaded per face
§3 Templates include "data binding" Does not exist — zero hits repo-wide for dataBinding / dataSource / csvBind. The other five capabilities are real and tested
§4 "No 3D gizmo snapping… a switch wired to nothing" Already deleted, with deadLayoutState.test.ts keeping it deleted
§4 "the SQLite local index and version store both exist and are tested" Half true, and the wrong half was load-bearing. The version store is real; LocalIndex is a declared interface with no implementation — better-sqlite3 is not a dependency, so index:available is permanently false, and no caller ever writes a row
§4 "no DOF code in packages/renderer" Still true for a renderer pass. Flat quads now strip-subdivide in buildSnapshot (dofStrips.ts, 2026-08-14); per-pixel iris still blocked

Two defects were fixed rather than merely recorded, and both were the same shape — a value honoured at one end of a boundary and silently dropped at the other:

  • Three light parameters stopped at the CPU. coneFeather, the AE falloff curves and a light's Point of Interest were read by shadeLayer and absent from ShaderLight, so the shader hardcoded a 20 % feather, degraded every falloff curve to linear, and tested the spot cone with a 2D aim no POI could reach. lightShaderParity.test.ts now fails if a field the CPU reads does not reach the GPU producer.
  • A spot light's cone did nothing on a 2D layer. Every type rasterized to the same isotropic circle and the wash texture was cached on colour alone — which was a collision, not just a narrow key. Cone angle, cone feather and light angle were three shipped inspector controls with no visual effect.

Corrected 2026-08-10 (second pass)#

Claim Reality
Expressions are a small "curated" API, "~18 functions" ~50 identifiers, including velocityAtTime, key(n), numKeys — so the AE bounce/inertia idiom class ports as-is. No architectural limit on sampling away from the current frame
§3 "easing presets" There is no easing-preset registry. Bezier handles + Easy Ease assistants; BOUNCE_EASE is one cubic-bezier used by one preset and cannot express a decaying bounce
The transparency checkerboard was missing It existed, as a full-bleed .stageTransparent on the stage — which is why a transparent comp looked like the surrounding panel had changed. Now clipped to the comp rect. (The earlier "only the Checkerboard effect exists" finding was a truncated grep, not a fact)
Layer label colours are unbuilt Already ship: a 12-entry palette on custom.labelColor (labelColor.ts), persisted through sceneProjectIO, read by Scene rows, timeline track headers and clip bars
SelectionPass draws the selection chrome It does not. snapshotToFrameScene sets selection: [] unconditionally, so the pass never draws in preview or export; the real outline and handles are 2D-canvas overlay chrome in useWorkspace.ts. That overlay now tints each outline with its layer's label colour (dark halo underneath for contrast); handles follow only when exactly one layer is selected
The boot CSP error was cosmetic It broke a feature: media="print" never flipped to all, so no user-selectable document font ever loaded. The UI looked right because its own faces come from a different @import

Corrected 2026-08-10 (third pass — camera)#

The first two rows are corrections to this file's descendants rather than to itself, and they are the reason retiredDocClaims.test.ts now exists: §5 was a ledger inside one document, so a claim retired here stayed live wherever it had been copied. The guard is repo-wide; this table remains the exemption, because retiring a claim requires quoting it.

Claim Reality
README.md and ROADMAP.md: "58 effects" 73, from featureCounts.cjs, the same extractor §1 uses. The count guard existed but was scoped to this file's marked table, so the number was corrected here and left wrong in the two most-read files in the repo

Corrected 2026-09-01 (motion-quality pass)#

Claim Reality
§4/§5 "no DOF code in packages/renderer" (retained as true on 2026-08-10) No longer true. The renderer owns two DOF shaders: coc-blur (per-pixel CoC interpolated from four corner radii — planDofCocCorners in dofStrips.ts plans them) and bokeh (polygonal iris gather), both in builtin.ts and dispatched by CompositionPass. Strip subdivision is now the fallback, not the mechanism
CAMERA_SYSTEM.md §7 "a layer that spans a range of depths gets one uniform blur, not a gradient" Superseded by the corner-CoC path — the blur radius varies per pixel across the quad. Still per-layer: no cross-layer depth-buffer gather, which remains the honest gap
A static 3D layer under a keyframed camera renders sharp (motion blur gated on the layer's own tracks only) Fixed 2026-09-01: CAMERA_MOTION_PROPS on the active camera extend the gate to all 3D layers, with per-sub-frame camera poses (buildSnapshotCameraMotionBlur.test.ts)
CAMERA_SYSTEM.md §8.2 restated the retired per-quad lighting claim Retired the day before, in the 2026-08-10 row above. Corrected there; the shadow half of that sentence (2.5D projections) was and is true and was kept
Cameras have no in-place X/Y rotation, so a tripod pan is inexpressible Was true, now built. orientationX/orientationY are Transform scalars composed as OFFSETS onto the base aim in cameraFromNode, so they also work on a two-node camera without breaking its tracking. Rx/Ry were already in the world → camera matrix, driven only by orbit/look-at — the matrix alone did not reveal that, the value trace did
The camera orbit/pan/dolly tools are keyboard-only They are not. Three visible toolbar buttons in SceneControls.tsx (CAMERA_TOOLS), plus the C-key cycle and a Tools-menu entry. Believed missing anyway, which is a discoverability lesson rather than a code one
Auto-Orient "Along Path" is meaningful on a camera It was offered and did nothing. Fixed 2026-08-11, and it was wider than reported — see the row below

Corrected 2026-08-11 (fourth pass — the footage column)#

These four were written into §4 earlier the same day and were wrong within hours. Recording them in full because the failure mode is new and worth naming: the first three came from absence greps — one candidate name per feature, zero hits, claim written — and the fourth from quoting a stale comment inside a source file as if it were current behaviour. §0 warns that prose is not under test; it did not occur to me that a code comment is prose too.

§4 claimed (2026-08-11, morning) Reality
"No proxies. All 74 proxy hits are network proxy" False. src/core/assets/proxy.ts + proxyManager.ts + three test files are a measured proxy system with an export-polarity invariant. The "all network" claim came from listing the first ten alphabetical matches, which happened to be aiTransport.ts / csp.ts — a sampling artifact stated as a census
"No footage interpretation — no alpha interpretation, conform-frame-rate or loop count" False. sourceInfo.ts holds FootageInterpretation with exactly those fields, stored per-asset. Pulldown removal was the one named item genuinely absent at the time (shipped 2026-08-20: detection + inverse telecine in the exact decode path)
"The source frame rate is unknown" (Tier 1) False, and fixed 12 days before it was written. mediaProbe.ts (2026-07-30) defines three tiers — probed (desktop + ffprobe: rate, duration, PAR, codec, audio inventory), elementOnly, none. It is wired: buildSnapshot.ts:1811 reads footageSourceOf(node)?.fps ?? fps. Verifying the read path, not just the existence of the module, is what settled this
Quoted videoFrameCache.ts's "KNOWN LIMIT" header as current The header was written 2026-07-21 and superseded 2026-07-30. The file's own bracketFrames already takes fps as a parameter; the caller supplies the probed rate. A comment describing a limit is not evidence the limit still holds

Fixed 2026-08-11 — Auto-Orient, and the dead-control class#

The camera row above understated it. autoOrient has exactly two readers, both inside buildSnapshot's drawn-layer loop — and that loop continues past group/null/camera/audio at its top and diverts light a few lines later. So the dropdown was dead on five kinds, not one. null is the one that stings: auto-orienting a null with children parented to it is a standard AE rig, and the control looked available.

Fixed by giving the concept the predicate threeD.ts already models — one canAutoOrient(node), so "a switch never lights up without pixels changing". Motion Path is deliberately not gated on it: smoothing a camera's position keys is real, only the derived rotation was dead.

autoOrientKindParity.test.ts guards it, and it does the thing §0 asks for — it derives the dead set by parsing the skip list out of buildSnapshot.ts rather than restating it, so editing that loop fails the test until the predicate agrees. Falsified before being trusted: dropping null from the set fails the parity assertion.

This is the fourth control of this exact shape in the repo — after the spot cone that did nothing on a 2D layer, three light params that stopped at the CPU, and frameBlend writing a flag no renderer read. Each was cheap to keep and cost nothing to run, which is why each survived. The open question is not this control but the class: nothing yet scans for the fifth.

Built 2026-08-11 — audio effects, and the parity they had to satisfy#

AE ships ten audio effects; this app shipped none — audio was levels, automation, waveform, spectrum and mixdown, i.e. playback and analysis with no processing. Four now exist: Parametric EQ, Bass & Treble, High-Low Pass and Delay.

The design constraint was already written down. audioParams.ts says level was "the first property through this seam; pan, fades and audio-effect parameters are the same shape and should reuse buildParamRamp rather than growing a second scheduling path" — because a mix that sounds right while scrubbing and renders differently is discoverable only by exporting a file and listening to all of it.

So there is exactly one builder, connectAudioEffects, called by both AudioEngine (live) and audioMixdown (offline). It takes a BaseAudioContext, not an AudioContext, specifically because typing it the narrower way is how a live-only path gets written by accident: the offline call would fail to compile and get "fixed" with a second implementation. audioEffects.test.ts reads both call sites and fails if either stops going through it — falsified by un-wiring the export path, which reddens exactly that assertion.

Chosen for having an exact Web Audio node and therefore structural parity: the biquad family is the same object with the same maths in both context types. The six not built are decisions, not oversights — Reverb needs a ConvolverNode and an impulse response to ship with it, Flange & Chorus a modulated delay, Backwards is a buffer transform belonging where decoding happens, Tone is a generator with no input, Modulator is ring modulation, and Stereo Mixer interacts with the mixdown's channel handling.

Two details worth keeping: delay is dry/wet in parallel, summed — a DelayNode in series is latency, not an echo — with feedback capped below unity so an offline render cannot ring forever; and an empty chain returns the input node untouched, so a project without effects builds the graph it always did.

Investigated 2026-08-11 — the clamp01 consolidation, and what actually broke#

There are ~17 hand-written clamp01 copies across src/core, and they do not agree. Most are v < 0 ? 0 : v > 1 ? 1 : v, which returns NaN for NaN — both comparisons are false, so the value falls through untouched. Two guard it explicitly and return 0.

Consolidating them onto the NaN-safe form looked like an obvious cleanup. It moved 112 render-test scenes and lost fidelity on 49 (from a baseline of 3 regressions and 0 losses). Reverted in full; the gate is back to 3/0.

RESOLVED the same day. The first explanation here was wrong: it said NaN propagation must be load-bearing. It is not.

A second experiment isolated the variable. A variant differing from the original only on NaN (v !== v ? 0 : …), applied to all 12 files, left the gate at its 3/0 baseline. So NaN was never involved. The two forms differ on a second input nobody was thinking about:

naive:    v < 0 ? 0 : v > 1 ? 1 : v      →  undefined  ⟹  undefined
NaN-safe: v > 0 ? (v > 1 ? 1 : v) : 0    →  undefined  ⟹  0

undefined satisfies neither comparison, so the naive form returns it untouched. At vectorDraw.ts applyStrokeStyle, ctx.globalAlpha *= clamp01(stroke.opacity) then assigns NaN — and the Canvas2D spec says a non-finite globalAlpha assignment is ignored, so the previous value stands and the stroke draws fully opaque. Map undefined to 0 instead and the alpha really is 0: the stroke disappears. That is the 112 scenes.

So the rendered result of a missing stroke opacity rests on a coincidence of two unrelated leniencies — a clamp that passes non-numbers through, and a canvas that discards NaN. Neither is a decision anyone made. strokeOpacityGuard.test.ts pins the mechanism (including the exact substitution that broke it) so it is not rediscovered by running the GPU suite.

Both were then done, in that order. applyStrokeStyle now says Number.isFinite(stroke.opacity) ? stroke.opacity : 1 — a stroke with no stated opacity is opaque because the code says so, which reproduces the old accidental result exactly. With that in place, 14 of the 17 clamp01 copies collapsed onto one in @utils/lang, and the render gate stayed at 3/0.

Three copies remain, each for a reason rather than by neglect:

File Why it stays
sceneInsert.ts Signature is number | undefined — a different contract, not a duplicate
templatePreview.ts Local shape; left pending its own check
packages/design-system/src/color.ts Different package; @utils is an app-level alias it cannot reach

The lesson worth keeping: the duplication was never the bug. A permissive expression at one call site was, and the seventeen copies only made it expensive to find. Unifying first and asking later cost 112 scenes; fixing the call site first made the same change a no-op.

A second lesson, cheaper: the consolidation was done with a regex whose optional (?:/\*\*[\s\S]*?\*/\n)? docblock group matched from the module docblock all the way to the target function, deleting everything between — 579 lines from layerStyles.ts, 42 from waveform.ts, and the whole parser from cubeLut.ts. git checkout recovered the tracked files; the untracked one had to be rewritten. Bulk edits across many files want per-file verification, not one regex and a green typecheck at the end.

Corrected 2026-08-11 — "No Wiggle Paths" was wrong, twice over#

§4 listed Wiggle Paths as a missing shape operator, on the strength of a grep for wigglePath returning zero. It returns zero because the operator is stored as roughen and labelled "Wiggle Paths"PathOpControls.tsx says so in a comment, and pathOps.ts's own module header says "Roughen (AE's Wiggle Paths)". A complete second operator was written before the label was noticed: two entries in one menu, both named Wiggle Paths. That is the fifth absence-grep error in one day, and the first that would have shipped a duplicate feature rather than a wrong sentence.

What was genuinely missing was one parameter. AE's Roughen displaces along the normal with every point independent; AE's Wiggle Paths adds Correlation — how alike neighbouring points move — and that is the whole character of the operator. Without it, the thing carrying the name was the other effect: uncorrelated noise shreds an outline, correlated noise makes it undulate like something with stiffness.

Correlation now exists on the operator, keyframeable (unlike seed, where interpolating scrubs through unrelated noise fields), clamped 0..100, and defaulting to 0 — byte-identical to the previous output. AE defaults it to 50; matching that would have re-shaped every Wiggle Paths already in a project. wigglePathsCorrelation.test.ts pins the no-op default separately and first, and asserts that only ONE operator wears the name.

Remaining honest difference from AE: displacement is still along the normal only. AE's Wiggle Paths moves points in 2D. That is a separate change with a real visual consequence, so it is recorded rather than smuggled in.

Built 2026-08-11 — a physical lens model, and why per-pixel DOF is blocked#

Per-pixel DOF cannot be built today, for a reason worth writing down before someone plans a sprint around it. A depth-driven post-process needs a sampleable depth buffer, and neither backend has one:

  • WebGL2 attaches depth as a WebGLRenderbuffer (renderbufferStorageMultisample, DEPTH_COMPONENT24). A renderbuffer cannot be sampled, by construction.
  • WebGPU creates its depth texture with usage: TEX.RENDER_ATTACHMENT and no TEXTURE_BINDING, so it cannot be bound either.

Fixing that means a depth-texture path on both backends plus an MSAA-depth resolve story (WebGL2 cannot resolve a multisampled depth texture directly), and the render-test gate runs on the harder backend. It is a project, not a pass.

So the maths was fixed instead, which is the half that was actually wrong. dofBlurPx was |d − S| / S × aperture — a normalised-distance ramp, not a circle of confusion, and symmetric: a layer the same distance in front of the focal plane blurred exactly like one behind it, background blur grew without bound, and focal length changed nothing at all.

There is now a thin-lens model, CoC = A·f·|d − S| / (d·(S − f)) with A = f/N, selected by the presence of an fStop on the camera. Absent ⇒ the legacy ramp, unchanged, so no existing project is re-graded — the same opt-in shape as lightFalloffAt's 'none'. It is asymmetric, it saturates behind the focal plane (a distant and a very distant backdrop finally look alike), and a long lens is now shallower than a wide one at the same f-number. Degenerate rigs — focus inside the focal length, zero f-stop, zero depth — resolve to the Blur Level cap rather than to NaN, because a NaN radius does not throw, it silently blanks the layer.

No iris, blades, roundness or highlight-gain parameters were added. The per-layer blur cannot honour them, so they would have been five more dead controls. They belong with the per-pixel pass, whenever the depth-buffer work above is done.

Built 2026-08-11 — Compound Blur#

compound-blur (effect 75) blurs a layer by the LUMINANCE of another layer: the third member of the read-a-second-layer family after Displace and Set Matte, built on the same shape — a second texture at binding 3, the same target UV, the same borrow of MATTE_TARGET, the same self-fallback when the map is unset.

One pass with a scaled kernel, not a separable blur. A separable Gaussian is two passes sharing one radius, and the whole point of this effect is a radius that differs per pixel — there is no pair of 1D passes that produces a spatially varying kernel. So it samples a 13-tap golden-angle rosette whose SPACING scales with the local radius: constant cost, and quality that degrades at large radii into a slightly noisy blur rather than a smooth one. That is what "Max Blur" being a ceiling rather than a promise means; on high-frequency periodic content the residual reads as grain.

It shipped inert for an hour, and the reason is worth keeping. The type went into the EffectType union and every downstream piece was built — shader, material, uniform packer, renderer branch, scene — while the entry in EFFECT_DEFS was missed. tsc stayed clean and the suite stayed green, because almost everything downstream keys off the DEFINITION rather than the type: GPU_ONLY_EFFECTS is EFFECT_DEFS.filter(d => d.gpuOnly), so an effect with no def is not GPU-only, and extractSpatialEffects therefore dropped it on every baked layer. effectRegistryComplete.test.ts now checks the registry against EFFECT_CATEGORY, which the compiler already forces to be exhaustive.

Built 2026-08-11 — Apply Color LUT, and a miscount in the counter#

apply-color-lut (effect 74) parses .cube 3D and 1D LUTs and samples them trilinearly — the one colour tool per-channel curves cannot stand in for, since a 3D LUT can rotate one hue while its neighbour holds still. Filed under Color Correction rather than AE's Utility folder, because a one-item folder is worse than a slightly wrong one.

It samples the project working space (srgb-linear or aces-cg). A LUT authored for a different encoding (log, display-referred) will not match its author's intent unless the working space matches; that is the documented limit vs full OCIO (no roles / displays / views). See cubeLut.ts.

Adding it exposed a bug in featureCounts.cjs itself: unionMembers regex-matched '…' runs in the union body without stripping comments, so the apostrophe in a comment reading "AE's Apply Color LUT" opened a quote that closed on the next real member — inventing one member and eating another. One new effect moved the count by two, which is the only reason it was caught; a comment worded slightly differently would have moved it by zero and under-reported silently, forever. This is the worst version of the bug the script exists to prevent: the number that is supposed to be beyond hand-miscounting, miscounted.

Fixed by stripping comments first, and unionMembersIn is now split from its file wrapper — the same shape as objectKeysIn — so docFeatureCounts.test.ts can splice a union and prove that prose does not move the count while a real member does. Falsified: reverting the strip fails five assertions and returns the bogus 75.

Built 2026-08-11 — Essential Properties, and the trap in it#

An override has two halves, and building either alone ships a dead control:

  • staticexpandCompInstances patches the clone's components.
  • animatedbuildSnapshot's anim shim drops the overridden prop, so the SOURCE node's track stops outvoting that patch on every frame.

With only the first, an override works on a static layer and silently does nothing the moment anyone keyframes it. Three further traps, each caught by a failing test rather than by reading:

  1. materializeForFrame is a whitelist. __overriddenProps was dropped in transit until it was named there, exactly as __instanceSource must be.
  2. A sealed instance never expands. Collapsed instances expand inline and get patched clones; a sealed one — the default — is rendered by a recursive pass over the referenced comp's REAL nodes, where no clone exists. It needed comp.compOverrides handed down, replacing rather than inheriting through ...comp so an outer instance cannot leak into a nested one.
  3. The prop is not always on the Transform. readBase is last-write-wins and opacity lives on Style, so patching Transform lost every time. Overrides now target the last component that already declares the prop.

Falsified before being trusted: disabling the shim fails exactly the two animated assertions and nothing else.

Also fixed: videoFrameCache.ts's "KNOWN LIMIT — we do not know the source's frame rate" header, resolved twelve days earlier and still asserted in the present tense. It is corrected in place rather than deleted, and says why it was kept — a stale comment is prose, and §0's warning applies to it too.

What survived the recheck, verified rather than assumed: decode really is HTMLVideoElement + seek and deliberately not WebCodecs (document .createElement('video') in both videoFrameCache.ts and AppTextureProvider .ts); normalMap's single hit is a plugin test fixture (depthPluginRebuild.test.ts), not a renderer feature; outputModule has no hits in .ts/.tsx at all; and aces at 255 hits was pure substring noise — "surfaces", "traces", "interfaces" — with no ACES anywhere.


Built 2026-08-12 — the first CPU effect ported, and the contract for the rest#

Beam is the first of the 112 to move onto the GPU. Chosen because it is in the priority family (light/glow/flare), it is a procedural generator — the easiest class to write as a shader — and, decisively, it already had a committed reference blessed from Canvas2D. That last point is what made the port verifiable on the first render with no new harness: the golden is the CPU implementation, so "render both, diff, promote within tolerance" is the existing scene gate. It passed at the default 0.5% tolerance, on both backends, with no new entry in the WebGPU ratchet.

The CPU version strokes the segment twice with globalCompositeOperation = 'lighter' — a wide soft pass at alpha 0.35, then a narrow bright core, both round-capped and both faded by a gradient running tail → head. The shader is a capsule SDF (round caps are the capsule) with a 1-texel smoothstep for the antialiasing Canvas2D does on a stroke edge.

The part worth copying is the coordinate space, because it is where this kind of port goes wrong silently. The CPU implementation works in the LAYER's own pixels — startX is a percentage of the layer's width — while the 2D effect chain runs in a SCREEN-space buffer where the layer is a sub-rect. Endpoints therefore resolve against fxBox, the same quantity the gradient ramp uses for the same reason, and thickness converts through the chain's kx/ky. Reading those as fractions of the buffer would have put the beam somewhere else entirely, at the wrong size, on every layer that is not full-frame.

No new per-effect flag was needed. Membership of CANVAS2D_ONLY already IS the selector: leaving that set means the effect no longer forces a bake, while staying in CANVAS2D_IMPLEMENTED keeps the Canvas2D pass for layers baked for other reasons. That is exactly the position apply-color-lut and Fill/Stroke/Sharpen/Noise occupy, and re-using it beats inventing a parallel mechanism.

portedEffectContract.test.ts states the contract once and applies it to every ported effect, because a port is four coordinated edits and three of the four failure modes are silent:

Break this What you see
still forces a bake the port buys nothing; the layer still rasterizes
Canvas2D pass dropped the effect vanishes only on layers baked for some other reason
not emitted to the GPU the port is inert and the effect does nothing
emitted on a baked layer too it applies twice — reads as "too strong", not as a bug

The fourth rests on the effect not being marked gpuOnly, which is asserted separately rather than left to follow: a gpuOnly port would satisfy every other assertion in the file and still double-apply.

Measured 2026-08-12 — sizing the GPU port of the CPU effect population#

The standing plan calls for moving the Canvas2D effects onto the GPU, and the first question is a ratio: month or quarter. scripts/effectPortTriage.cjs answers what can be answered exactly and refuses the rest.

CPU-baked effects (CANVAS2D_ONLY) 133 of 175
already a pure (data, w, h, …) kernel 113 (85%)
need a whole-image reduction 4equalize, auto-levels, auto-contrast, auto-color
drawn with canvas ops, no pure kernel 16

The 133 (112 before round five; Curl Noise is the 133rd) confirms the figure every brief has been quoting; unlike the effect count, this one was right. Derived from the predicate rather than a copy of the list, and unaccounted: 0 — every one of the 175 lands in exactly one bucket, so there is no silent third category rendering as a no-op.

The 82% is the finding that decides the answer. The pixel work is already separated from the Canvas2D plumbing: applyMedian is four lines around medianData(data, w, h, radius), applyAutoLevels is applyInPlace(oc, w, h, d => autoLevelsData(d, …)). A pure array kernel is the form a fragment shader is translated from, so for most of the population the port is a translation rather than an untangling. That is a month-shaped problem, not a quarter-shaped one — and it is a property of the code as it stands, not a plan.

Only 4 need more than a fragment shader. A shader sees one pixel and its neighbours; it cannot see a histogram without a separate reduction pass. Those four are a different piece of work and should be scheduled as one. Every other kernel is per-pixel, neighbourhood or warp — and neighbourhood sampling is easier in a shader than on the CPU, which is the case shaders exist for.

The 20 with no pure kernel are canvas draw calls, and they are not a homogeneous group: ten are procedural generators (checkerboard, grid, circle, ellipse, radio-waves, lightning, beam, lens-flare, light-rays, light-sweep), three are interior layer styles needing the alpha silhouette, and two — numbers and timecode — rasterize glyphs, which is the genuinely awkward corner. Worth noting against the stated priority of doing the light/glow/flare family first: lens-flare, light-rays, light-sweep and beam are all procedural generators, which are among the easiest shaders to write. The priority and the difficulty agree.

What this deliberately does not report: the mechanical-vs-hard split. Three heuristics were tried and all three produced confident, wrong numbers — recorded in the script's header so a fourth attempt starts from them:

  • reading only the dispatch handler gave 94% mechanical, because the handlers are wrappers and the work is one call deeper;
  • dx/dy as a neighbourhood signal classified Vignette — a per-pixel radial falloff — as neighbourhood sampling, since dx is as often a distance from a centre as a tap offset;
  • negative-init loops caught Median and missed Find Edges, because a 3×3 Sobel indexes its neighbours directly with no tap loop to find.

Two further false positives were caught only by hand-checking every flagged kernel: a body slice that ran past its own closing brace, and — the one worth remembering in this repo specifically — a match on the word "histogram" inside a doc comment describing a different effect. Signals get read from code with comments stripped, for the same reason docFeatureCounts.test.ts does it.

Built 2026-08-12 (evening) — simulation, phase 1: the seek architecture#

The one genuinely empty AE class in this audit, now started. What exists is the subsystem core, not a user-visible effect — stated plainly because this branch has already shipped one thing that was complete, tested and reachable by nothing.

SimulationCache makes a history-dependent layer answer stateAt(f) the way every other layer here does. It holds exactly one invariant:

stateAt(f) does not depend on which frames were asked for before it.

Scrub to 200, seek back to 10, play forward, export out of order — each must give bit-identical state to stepping from 0. Without it a preview and its export disagree, which MOTION_FORMAT_FREEZE.md treats as disqualifying.

That invariant is what forbids the obvious design. A cache holding "the current state" and stepping it forward is correct for monotonic playback and wrong on the first backward seek — and since monotonic playback is also the common case, a test suite mirroring real usage would miss it. The access orders in the tests are therefore deliberately hostile, and the reference answer is always naive stepping from 0 with no cache at all.

The open design question resolved to a structural answer. The scoping note asked "how far may a seek pre-roll before restarting is cheaper". Never: restarting means stepping from frame 0, frame 0's snapshot is pinned and never evicted, so the nearest snapshot at or before any frame is always at least as close as 0. The question presupposed a seek could land with nothing behind it, which pinning makes impossible.

bounceSim is the proof case, chosen because collision is the cheapest thing a closed form cannot express: position at t depends on the bounce count, each bounce on the velocity at impact, each of those on the bounce before. The test asserts a bounce actually occurs rather than assuming it — without one the motion is ballistic and the whole subsystem is unnecessary.

Both load-bearing mechanisms were falsified: letting nearestSnapshotAt pick a snapshot after the target fails 4 tests, and making clone() share typed-array buffers fails 8.

Not done, and not pretended otherwise: no layer kind, no effect registry entry, no renderer path — all of which live in files another session is currently rewriting. Phase 1 is the seek machinery and its proof. GPU state (ping-pong targets, the right shape for a fluid or wave field) is deliberately absent: designing the seek layer around a texture round-trip before anything needed one would have been backwards.

Retracted 2026-08-12 (evening) — "both WebGPU debts have collapsed" was a unit error#

The entry that stood here claimed glass-grain had fallen from 32.712 % to 0.192 % and that the map-reading divergences were resolved. It was wrong, and wrong in the way this document exists to catch: two different metrics read off two different tools and subtracted.

  • The ratchet (comparator.mjs) counts pixelmatch diffs: ratio = diffPixels / totalPixels, flagging any pixel past a small numeric threshold, sub-perceptual ones included.
  • analyze-gap.mjs counts only pixels past a perceptual threshold, then classifies each as contour or flat.

Both print "percent of pixels differing" and they are not the same population. glass-grain is 0.32712 by the first and 0.192 % by the second on the same frame; nothing moved.

The tell was there and got walked past: light-ambient has a ceiling of 0.63873 while analyze-gap reports 89.026 % — and the gate had just said all 26 known divergences held at their ceiling. A frame cannot both exceed its ceiling and hold at it. That contradiction was visible in the same output as the numbers being compared, and it was noticed only when a second cluster was checked for a different reason.

What still stands, because it needs no cross-tool comparison:

Scene analyze-gap verdict
effect-compound-blur, effect-turbulent-displace 0 pixels past the perceptual threshold
effect-displacement-map, -layer 0.790 % / 0.515 %, 100 % contour, zero flat

That is a statement about perceptual difference only. run.mjs reports effect-compound-blur as NOT matching its committed reference, so pixels do differ; they differ sub-perceptually. Both facts are true and the earlier entry collapsed them into "pixel-exact", which the tool never said.

Also retracted: the claim that another session's work closed these. No before/after was measured on a comparable metric, so the improvement itself is unestablished, let alone its cause.

What remains true and useful. Alpha is still eliminated as the map-reading lead, by inspection rather than measurement: the map is an opaque gradient and the subject sits inside it, so alpha is 1 where the divergence was reported, and premultiplied rgb IS straight rgb at alpha 1. Both recorded leads on that investigation — orientation, then alpha — were wrong.

The remaining flat-difference cluster, by analyze-gap's measure, was light-ambient, layer-styles, mask-feather, three-d-dof-visible, light-cast-shadow and light-spot. layer-styles and mask-feather exited (2026-08-25): GPU blur follows CSS sigma semantics (σ = radius, ±2.5σ), proven on a hard-edge isolation (blur-hard-edge + cssBlurKernel.test.ts); both goldens re-blessed from WebGL2 as expect-pass. Still open in that cluster: light-ambient, three-d-dof-visible, light-cast-shadow, light-spot.

Swept 2026-08-12 — the Renderable boundary is clean, and now stays that way#

Renderable is the entire contract between the snapshot builder and the renderer, and the extrusion defect above was one field of it (effects) going unwritten on one path. So the whole interface was swept: 30 fields, checked against every producer.

No new defect. Three fields looked unproduced and all three were explained:

Field Why it looked dead What it is
depthExempt Detector matched only field: form Written as r.depthExempt = true — an assignment, not a literal
maskId Nothing writes it Read only by MaskPass, enabled = false by design
clip Nothing writes it The other half of that pass's filter

MaskPass is deliberate scaffolding, not an oversight — its docstring says "enable + wire a masked material to activate", and RenderGraph.ts carries an optimization built specifically around it being permanently off (its target had been allocating ~8 MB of VRAM per frame for a pass that cannot run). Masking ships through effectBake, which reads maskId off the effect, not the renderable — which is why a repo-wide grep for maskId looks busy while the renderable field stays untouched.

The sweep then widened to the whole boundary — RenderLayer (73 fields), Renderable, SceneLight3D and FrameScene, 120 fields, all produced. It is now renderableFieldCoverage.test.ts rather than a one-off, so a field added to any of the four that nothing produces fails a test instead of shipping as a control with no effect.

Two of the sweep's own bugs are pinned in it, because both are the failure mode this document keeps recording — a detector that is wrong in the direction of looking right:

  • It first reported Renderable as having four fields. indexOf('export interface Renderable') matched RenderableSdf, declared earlier in the same file. A prefix match returns a real interface with real fields, so every count derived from it is wrong and nothing looks amiss.
  • It then reported depthExempt as never written, by matching literal syntax and calling that production.

A third followed when the sweep widened: hasEffects and strokes reported dead because they are written as bare ES6 shorthand (hasEffects,), which has no colon and no dot for a punctuation-reading detector to find.

And the producer LIST turned out to be wrong in both directions. Too short, and the sweep invents dead fields — swept against the Renderable producers, SceneLight3D reported seven, including halfConeRad, which ships and has its own parity guard. Too long, and it silences real findings while looking better researched: paintStrokes.ts sat in the table for one run, covering for the shorthand gap rather than for a real second producer. Both directions are now asserted, and each list is down to the single module that actually builds the type.

Every one of these was caught by checking a surprising result rather than reporting it. The first version of this entry would have claimed a dead pass and two dead fields; the second would have claimed two more.

Fixed 2026-08-12 — this branch shipped its own dead control#

exportPresets and importPresets landed with a 14-test suite covering the round trip, overwrite-by-name, hostile files and version refusal. All 14 passed on a build where neither function had a caller anywhere in src/ — no menu entry, no file picker, nothing. A user could not reach the feature at all.

That is the same shape as Command.isChecked, isPassthroughOnly and SelectionPass, each recorded above as a control the code declared and nothing consumed. Those were inherited. This one was written here, in a branch whose running theme is finding exactly this, which is the part worth recording: a thorough test suite over the model is what made it feel finished. Tests over a model say nothing about whether anything reaches it.

Now wired into the presets panel's settings menu, with the reachability tested by rendering the panel rather than by grepping for the import — a source-level check would have caught the original miss but passes on a menu item that renders disabled forever.

Two smaller things came out of it, both recorded because the first was a wrong claim of mine that the tests caught:

  • The Export entry disables itself when the bundle would be empty. The first implementation counted listPresets().filter(p => !p.builtin), and the commit message drafted for it asserted that this was broken because "only some compiled-in presets carry the flag". Measured: all 73 carry it, so the filter was correct and the claim was invented. The count was still moved to countUserPresets(), which shares readUserPresets with the exporter — not to fix a bug, but because the filter is only right for as long as every entry in all five shipped arrays stays flagged.
  • One test was removed for being vacuous. It claimed to guard the input.value = '' reset that lets a user re-pick the same file after fixing it. It passed with the line deleted: jsdom never assigns a value to a file input. The reset is still there and is still right; it is now recorded as UNVERIFIED rather than covered by a test that measures nothing.

Verified 2026-08-12 — time effects are four, not two, and all four run#

Listed as "2 of 145 (echo, posterize-time). Thin, heavily used." The count is now four: wide-time and force-motion-blur have joined, and both are implemented rather than merely registered — the check that matters, since this branch has already found three fields that existed only as declarations.

effect consumed by
echo buildSnapshot (ghost copies at sampled past transforms)
posterize-time buildSnapshot's time plumbing
wide-time temporalGhosts.ts, with its own test file
force-motion-blur readForceMotionBlur, read in buildSnapshot

All four are in the TEMPORAL set, which is what routes them through the time plumbing rather than the per-layer effect chain.

Worth recording how nearly this became a sixth wrong entry. A first search for wide-time scoped to src/core/rendering/ and echo.ts returned nothing, and "declared but unimplemented" was the obvious reading — the exact conclusion drawn correctly three times earlier in this branch. Widening the search to the whole tree found the consumer immediately. The lesson is not "search wider" but that a negative result from a SCOPED search carries no information about anything outside the scope, and the scope is chosen from a guess about where the code should live.

That leaves simulation as the single genuinely empty class in the backlog — the one item of six whose gap survived every check, and the only one whose evidence was a registry count from the start.

Scoped 2026-08-12 — simulation, and why particles are not a head start#

"0 of 145" survives scrutiny — it is a registry-derived count, not a symbol search, which is exactly the class of claim that held up. Simulation really is the empty AE class.

What needs correcting is the assumption that the existing particle system is a foundation for it. It is not, and the reason is a deliberate design choice worth understanding before anyone plans on top of it.

particleSim.ts is a closed-form emitter, stated in its own first line and visible in its core: hash01(index, salt, seed) derives every particle's lifetime, direction and phase as a pure function of its index and the seed. A particle's state at time t is COMPUTED, never integrated. Nothing accumulates.

That is what makes scrubbing free. Jump to any frame and the emitter answers immediately, because there is no history to reconstruct — and it is why particles need none of the snapshot/pre-roll machinery a simulation would.

Which is precisely the tension. A real simulation — collision, flocking, fluid, AE's Foam / Wave World / Caustics — is defined by state that DEPENDS on the previous frame. Adding one means introducing accumulation for the first time, and accumulation is what breaks random-access scrubbing. The prescription of "seeded sim + periodic snapshots + pre-roll on seek" is the standard answer to exactly that, and it is a new subsystem rather than an extension of particles:

  • what to store — ping-pong render targets holding state in textures work on both backends, so no compute shaders are needed;
  • when to snapshot, and how far a seek may pre-roll before it is cheaper to restart;
  • how the frame cache and the export path interact with a layer whose output is history-dependent, which every other layer in this renderer is not.

None of that is hard in isolation. All of it is new, and none of it is reachable by generalising the emitter — the emitter's whole architecture is the absence of the thing a simulation is.

So the honest estimate is a subsystem, not a feature, and the first design question is not "which sim effects" but "how does a history-dependent layer coexist with a renderer built on random access".

Verified 2026-08-12 — ping-pong time mode ships, in both directions#

Listed as a gap: "loopOut('cycle') exists; no pingpong token." There is a pingpong token, and it is implemented rather than merely declared — expressions.ts types the mode, evaluates it on both the out and the in side, and offers it in the editor's own autocomplete:

export type LoopMode = 'cycle' | 'pingpong' | 'offset' | 'continue';

Two evaluation sites, not one, which is the part worth checking: loopOut and loopIn each handle the mode, so a loop reflects before the first keyframe as well as after the last. A declared-only token would have shown up as the type plus zero consumers — the exact shape Command.isChecked, isPassthroughOnly and SelectionPass all had in this same branch, so it is worth distinguishing by hand every time.

continue extrapolates the last (or first) segment's speed past the keyframe span — AE's fourth loop mode. Sampled just inside the span so a hold-clamped valueAtTime does not report zero velocity at the endpoint.

Unknown modes fall back to 'cycle' deliberately, so a typo degrades instead of erroring — which also means "I typed pingpong and got a cycle" is a diagnosable symptom rather than the feature being absent.

Composed bounce / inertia / delayed-follow / wiggle / loopOut idioms are pinned in packages/animation/src/__tests__/aeIdioms.test.ts (velocityAtTime + key + layerAt + selfSpan + posterizeTime), not as separate builtins. suggestExpression maps natural-language bounce / inertia / follow / wiggle / pingpong / loop intents onto those same shapes.

Fifth wrong premise in that backlog, and the count is now the finding. The verify-first instruction attached to one item (variable font axes) should have been attached to all of them: of the six 2.3 entries, four were assessed by grepping for a symbol, and every one of those four was wrong. The two that survive — simulation and time effects — were the two supported by a COUNT derived from the registry ("0 of 145", "2 of 145") rather than by a search for a name. That is the difference between a measurement and a guess, and it holds across every item in this document.

Verified 2026-08-12 — shy and guide layers already ship#

Listed as a gap on the grounds that both have "zero hits". They have plenty; the search was for the wrong names.

Guide layers are complete, with AE's actual semantics — visible while you work, absent from the render. guideLayer.ts exports readIsGuideLayer, isGuideLayer and toggleGuideLayer; App.tsx surfaces the toggle per layer; and buildSnapshot enforces the behaviour in one clause:

visible: node.visible !== false
  && (!anySolo || node.solo === true)
  && !(comp.forExport === true && readIsGuideLayer(node)),

Note what that says: the exclusion is gated on forExport, so a guide layer draws in the viewport and drops out of the exported frame. That is the whole point of the feature, and it would have been easy to build the useless version that simply hides the layer.

Shy ships too, and correctly as timeline-only state: uiStore.globalShy, a "Hide Shy Layers" toggle in BottomTimeline carrying aria-pressed, a shy flag per layer, and its own icon. A comment in App.tsx states the rule explicitly — "shy is timeline-only state with no render meaning" — which is right: shy hides ROWS, never pixels.

This is the fourth premise in that backlog to be wrong on inspection, after "scale stepPx with depth" (already the behaviour), "no volumetric light rays" (four such effects ship), and "presets are per-machine so export is the gap" (true, but the save path exists and is named saveCurrentAsPreset, not savePreset). The pattern in every case: a grep for a plausible identifier, read as proof of absence. §0's rule covers this and is worth restating — absence of a symbol is evidence about the symbol, never about the capability.

Verified 2026-08-12 — variable font axes: one works by accident, the rest cannot#

Listed as "never checked — verify first". Checked, and the answer is sharper than present/absent.

Nothing implements them. Zero hits repo-wide for fontVariationSettings, variationSettings, or any axis tag (wght, wdth, slnt, opsz).

And nothing could, through the current path. Text is rasterized on a Canvas2D context via g.font = cssFont(s), and cssFont emits exactly this:

`${style}${s.fontWeight} ${s.fontSize}px "${s.fontFamily}", Inter, system-ui, sans-serif`

That is the CSS font shorthand, and the shorthand cannot express font-variation-settings — it is a separate property, and Canvas2D has no API for it at all. So this is not an unimplemented feature sitting behind a small patch; the axis values have nowhere to go until text stops going through ctx.font.

One axis does work, incidentally. fontWeight is carried as a string and lands in the shorthand as a numeric weight, and browsers interpolate a variable font's wght axis from a numeric weight. So a variable font already responds to the weight control, continuously, and nobody wrote code for that. italic likewise reaches slnt/ital where the family defines it.

Every other axis is unreachable. wdth would need font-stretch in the shorthand and there is no fontStretch field to emit; opsz and any custom axis have no shorthand form whatsoever.

So the honest scope of "add variable font axes" is not a text-style field plus a UI control. It is a different text rasterization path — glyph shaping that carries variation coordinates — which is the same prerequisite the outline-based text extrusion in §5 wants. Worth planning those together rather than separately.

Fixed 2026-08-12 — four small ones, all the same shape#

Each was a declaration nothing honoured, and each is the kind of thing that makes a larger change expensive later rather than being expensive itself.

Command.isChecked was a feature that did not exist. Declared on the interface — "Optional check invoked by menus to show toggled state" — with zero implementations and zero readers. So every toggle in the View menu rendered identically whether it was on or off: Show Grid told you the action existed and nothing about what it would do. WIRED rather than deleted, because six real toggles wanted it (grid, proportional grid, snap to grid, rulers, safe areas, motion paths) and both menu renderers already consulted enabled beside it. MenuItem now takes checked, renders the tick in the icon gutter menus already reserve, and switches to role="menuitemcheckbox" with aria-checked — a tick that exists only as a glyph is invisible to a screen reader. Held by commandIsChecked.test.ts, which asserts from BOTH ends: implemented by the toggles, and read by the renderers. Either half alone reproduces the original nothing.

SelectionPass could never draw. It rendered scene.selection, which snapshotToFrameScene set to [] unconditionally. Deleted, along with the selection field on FrameScene and its parameter on buildFrameScene — the outline and handles are, and always were, 2D-canvas chrome in useWorkspace.ts. OverlayPass.after had to lose the name too: a dangling after entry is silently NO constraint (compile() links one only when the named pass is active), which is the exact mechanism behind the grid-erasure bug its own comment documents.

MASK_TARGET was allocated every frame for a pass that cannot run. MaskPass is enabled = false and nothing turns it on, but target resolution walked the declarations without asking whether anything could still write to them — a full-viewport rgba8unorm, roughly 8 MB at 1920×1080, held for nothing. Fixed in the ALLOCATION, per the rule that the pass is not the problem. Deliberately narrow: a target is skipped only when it has declared writers and every one of them is disabled. The tempting rule — "allocate only what an active pass reads or writes" — would break the renderer, because CompositionPass uses the blur, backdrop and plugin-scale pools as scratch and most appear in no writes list at all.

Two capability lies. WebGL2Backend declared float16Textures: true as a field initializer and only corrected it in initialize(), so anything asking a constructed-but-uninitialised backend got a yes and could take the float branch on a context with no EXT_color_buffer_float. Defaults are now pessimistic — the value it is safe to be wrong about. NullBackend hardcoded the same true, so every headless test took the float branch and the 8-bit fallback (the branch that runs on the CI software rasteriser) was exercised by nothing; its capabilities are now mutable so a test can say which machine it is pretending to be.

And one that would have poisoned the GPU port. WebGL2Backend.createTexture ignored desc.format and always allocated RGBA8, while createRenderTarget honoured it — so one backend held two meanings for a format request depending on which function you reached. A ported effect allocating a float intermediate through createTexture would work on WebGPU, quantise here, and present as "that effect bands on WebGL2": a fresh mystery per effect instead of one wrong line. Fixed before any porting starts.

Unresolved 2026-08-12 — glass-grain moved and nobody knows why#

The WebGPU ratchet fired on glass-grain#0: 32.712% against a 32.183% ceiling. It is recorded here rather than quietly re-baselined, because an unexplained divergence is worth more as a written question than as a raised number.

Four hypotheses were tested by re-rendering, and all four were eliminated:

tested result
scene order — the two new extrusion scenes render before it removed them; still 32.712%
the MASK_TARGET orphan-skip allocation change disabled the skip; still 32.712%
the BEAM shader joining the registry array unregistered it; still 32.712%
the twelve one-sided shading edits in builtin.ts reverted them; still 32.712%

What remains from that session is WebGL2-only code (createTexture's format, the pessimistic capability defaults) or a field never set for a glass layer (shadeFor's oneSided) — and the WebGL2 reference comparison never moved at any point, which is what those changes would have disturbed.

Against that: 32.712% has now been measured in six independent process launches, while 32.183% was seen exactly once — the single sample taken when the baseline file was first written. A ceiling derived from one run of a frame with any run-to-run variance is a sample, not a bound.

So the ceiling was raised by hand, with the note attached in webgpu-baseline.json, rather than swept up by --update-backend-baseline — that flag would have re-blessed every entry at once and erased the question. The honest status is: either the baseline was low, or there is a real change nobody has located. Both remain open.

The wider lesson for that file: baselines for high-divergence frames should be the max of several runs, not one. The ratchet's 0.2pp slack is well-judged for a frame diverging 1% and tight for one diverging 32%.

Fixed 2026-08-12 — Compound Blur drew nothing on the primary backend#

250 scenes rendered on WebGPU and not one pixel of it was gated. The parity dashboard printed a number, labelled "measured, NOT gated", and never failed.

What the number was hiding: effect-compound-blur sat at 87.8% divergence against a reference blessed from the GPU, because its WGSL failed to compile. textureSample computes implicit derivatives, which WGSL permits only in uniform control flow, and the shader's radius < 0.34 early return makes the sampling loop non-uniform. So the pipeline was invalid and the effect drew nothing on the product's primary backend, while rendering correctly on WebGL2, whose GLSL twin has no such rule. The harness printed ERROR: 'textureSample' must only be called from uniform control flow on every run, next to a divergence figure that nothing acted on.

textureSampleLevel(..., 0.0) fixes it. Not a compromise: every source here is a non-mipmapped render target, so LOD 0 is the level implicit sampling would have chosen, and the WebGL2 path is untouched. Divergence 87.8% → 53.3% — it now draws, and what remains is a real WebGPU-vs-WebGL2 disagreement in the same scene that has not been diagnosed. Recorded as a debt, not explained.

The gate is a ratchet, deliberately, rather than a threshold. Byte equality between two hardware rasterizers is not a reasonable demand — the extrusion scenes measure 0.16–0.27% between backends, all of it edge antialiasing — so webgpu-baseline.json records a ceiling per divergent frame and the gate fails when a frame exceeds its ceiling, or when an unlisted frame exceeds 1%. 26 entries today.

That baseline is a list of debts, not of blessings: every entry is a disagreement nobody has diagnosed. It is deliberately not the divergence mechanism used for known Canvas2D gaps, which requires a stated mechanism per scene — demanding 42 diagnoses before any gate could exist is how the suite ended up with no gate at all. The run names frames that have improved enough to tighten, and --update-backend-baseline is a separate flag from --update so that re-blessing a reference cannot silently forgive a backend regression.

Also: a missing WebGPU adapter is now a hard failure in CI. It was a skip, with the reasoning that hosted runners have no adapter. True, and beside the point — a runner that renders no WebGPU turns every WebGPU gate (alpha semantics, 3D styles, plugin effects, extrusion reach, and now this ratchet) into a no-op that reports success. HARNESS_REQUIRE_WEBGPU=0 waives it explicitly for anyone who accepts the hole.

Corrected 2026-08-12 — the same count drifted into the same files, twice#

EffectType reached 145 while four places went on asserting 73: §4's "Effect breadth", README.md, ROADMAP.md, and — a fifth nobody had named — the §2 architecture diagram, which put the Zustand store count at 39 against a real 40.

(That sentence is phrased around the number rather than before it because the new guard flagged the first draft of this very paragraph. Which is the intended behaviour: a superseded count written as "N registry" in live prose is caught wherever it appears, including inside the entry announcing the guard.)

A row below records this happening ALREADY, at 58 → 73, together with the diagnosis: "the count guard existed but was scoped to this file's marked table, so the number was corrected here and left wrong in the two most-read files in the repo". The scope was never widened. One registry growth later the identical drift reappeared in the identical two files, and from there into every brief written against this document.

So the guard is now repo-wide: docPropagatedCounts.test.ts checks EDITOR_REFERENCE.md, README.md and ROADMAP.md for any claim of the form "N registry" and fails when N is not that registry's size.

The rule is deliberately narrow — a digit immediately followed by a registry's name. The obvious alternative, flagging any number in a paragraph that mentions a registry, was measured against these three documents and produced 39 hits, almost all noise: dates, millisecond measurements, effect indices, 3D. A guard needing a 39-entry allow-list is one that gets silenced the first time it fires. The cost of the narrowness is that an oblique phrasing still escapes, and §4's did — "Effect breadth: 73 vs AE's 400+" puts no noun after the number. That was rewritten into the checkable form rather than the regex being widened to chase it. Prose stating a count should say "183 effects".

Ledger table ROWS in this section are exempt, structurally rather than by a list of phrases: quoting a superseded number is what a corrections ledger is for, and a per-phrase list would mean every new entry here also had to edit a test. Prose in §5 — including every "Fixed …" narrative — is still checked.

Corrected in the same pass, because the paragraph was being rewritten anyway: §4 listed "no volumetric light rays (Shine)" and "no optical-flare system worth the name" among the missing classes. light-rays, lens-flare, light-sweep and beam all ship, each with a registry def, a Canvas2D reference, a Generate entry, and (as of 2026-08-14) a GPU shader.


Fixed 2026-08-12 — an extrusion's faces carried no effects at all#

buildSnapshot synthesized every extra face of an extruded layer with effects: undefined. Thirteen of fourteen renderables therefore dropped the layer's entire effect stack, and two separate user-visible symptoms came out of that one line:

Symptom Measured at 938dc23 After
An effect applies to the front face only invert changed 58.6% of the solid's pixels — its front face, and nothing else 100.0% (WebGPU) / 99.9% (WebGL2)
DOF does nothing on extruded objects 0.0% of wall pixels beyond a front-face blur's reach changed when DOF was switched on — the walls were byte-identical 50.4% changed; the wall's own outer edge spreads 11× wider than sharp

The scrub's stated reason was legitimate and is preserved: shadow-casting effects would stack N times inside the body, and CPU-baked ones cost a full rasterization per face. Those are now denied by name rather than by taking the whole list — see faceEffects.ts, which also records why the exterior set is exactly the one FACE_SURFACE_IDS already chose for layer styles, and why layer styles are excluded from the filter entirely (the overlays reach faces through styledSurfaceFill and would otherwise apply twice).

Spatial effects are bounded rather than banned. Measured on this machine, the marginal cost of one more effect-laden face is ~368 ms on WebGL2 against ~2 ms on WebGPU — a ~180× gap, both linear in face count. The budget is a single backend-agnostic constant (16 faces) because a face list that varied by backend would be a path that runs in one engine and not the other; it admits the box (5 faces) and the bevelled box (13) and excludes the populations that are large by construction — text slices (45), gradient wall strips (81), curved outlines (21+).

Held by ext-fx-invert / ext-dof-wall and their controls, gated through verify-extrusion.mjs. Deliberately not held by a golden alone: the front face is most of what a solid shows, so a reference blessed while the bug was live would have certified front-face-only forever, and every symptom of it passes a presence check.

Known and accepted: each face is its own quad and its own effect resolve, so a blur does not bleed across a seam. Measured, not assumed — the blurred body steps at most 7.5 levels between adjacent rows where the sharp control steps 74.7, so the join is a soft ridge and not a discontinuity. Removing it needs the faces resolved into one offscreen, which is a renderer change.

Fixed 2026-08-12 — every box was wound as if it had no far side#

extrusion.ts gave the left wall the same Ry(90°) as the right, and the bottom the same Rx(90°) as the top. A quad's normal is its own +Z axis, so each pair carried an identical normal, and since the two walls of a pair sit on opposite sides of the body, one of every pair pointed into the solid. Verified before the fix: r at x = +50 and l at x = −50 both reported normal (1, 0, 0); t at y = −30 and b at y = +30 both reported (0, −1, 0).

Two things kept it invisible for as long as it existed:

  • Lighting is two-sided. lightShading.ts and all twelve abs(dot(…)) sites in builtin.ts cannot tell a normal from its negation, so the wrong sign produced the same gain as the right one. A box lit hard from one side came out lit identically on both — which is what "it doesn't read as a solid" actually was.
  • Every assertion was about corners. Mirroring a quad within its own plane moves no corner, so all 70 extrusion tests passed before the fix and all 70 passed after it. Confirmed by reintroducing the bug: the six new winding assertions fail, and "left wall: d×h plane on the x = −w/2 plane" still passes.

The chamfer rings were always correct, and they are where the intended pattern was written: cfr Ry(135°) against cfl Ry(225°) — the far member of a pair is the near one plus 180°. The walls now follow the rings.

Pixel movement: none, and necessarily so. abs() makes the sign unobservable, so this changes no frame today; the render gate stayed green with no reference touched. It is what makes one-sided shading expressible at all.

Decision on abs(), recorded rather than deferred silently. The choice is to branch, not to remove it globally. Removing it globally is not merely disruptive, it is wrong: planeNormalOf returns the plane's +Z, and +Z is away from the viewer (project3d.ts), so a front-facing layer under a front light has dot(N, L) < 0. Clamping instead of taking the absolute value would render every front-lit 3D layer black. Two-sided is also genuinely right for the app's primitive — a 2D layer in space has no inside, and a layer seen from behind should still light.

So one-sidedness applies to exactly the faces that bound a volume: the synthesized walls and back cap of a geometric extrusion. Explicitly not the front face (it is the layer itself, and its outward direction is −Z, the opposite of the convention) and not the depth slices of a text extrusion (their normals are all +Z, so one-sided shading would black them out under a front light).

Implemented 2026-08-12. eyeLit.w carries it: 0 unlit, 1 lit two-sided, 2 lit one-sided — a third value in the existing flag, so the shade tail's std140 layout is untouched and no other packer moves. Four shade blocks (WGSL and GLSL, solid3d plus the shared *_SHADE3D_FN pair) derive it once and apply it at all twelve lambert and specular terms; shadeLayer takes the same flag, so the per-quad CPU fallback cannot disagree with the per-fragment shader.

Measured on the wall of a yawed box under a point light: gain 0.40 with the light on the wall's own side, exactly [0, 0, 0] with it behind. Two-sided returned the same magnitude for both, which is the defect stated as a number.

Two things did not go as predicted, and both are worth recording.

No golden moved. This was expected to move every lit-3D reference. It moved none — no committed scene combines an extrusion with a layer that accepts lights, because the lit-3D scenes are flat panels, whose front faces stay two-sided by design. So there is no regeneration commit, and the gate's silence here is a coverage gap rather than evidence of correctness.

VERIFIED IN PIXELS 2026-08-12, at the fifth attempt. ext-lit-toward / ext-lit-away mirror the light's Point of Interest and nothing else, so the geometry, the light's position, its radius and its wash are byte-identical between the two frames and a plain per-pixel diff IS the shading difference:

changed pixels max delta
one-sided 45.4% of the object 135.7 levels
two-sided (control) 0 0.0

Two-sided makes the frames byte-identical, because abs() cannot tell +0.77 from −0.77 — the defect expressed as an exact equality rather than a threshold. The front face is the control and it lives inside the scene: it stays two-sided by design, gain 0.401 in both frames, so a light that moved or weakened would show there.

Four earlier designs failed, all beaten by the light's comp-wide wash: moving the light changed it everywhere (identical 2.43× ratio for both builds); two boxes in one frame sat under different parts of its gradient; mirroring the box's yaw compared mirrored silhouettes (5.0 levels in both builds). What made the fifth work is a property of the model, not a tuning — a parallel light's shading ignores radius while its wash is stretched to 2 × radius, so a small off-frame light shades at full strength with its wash outside the viewport, and the wash does not read the POI at all.

The defect that hunt turned up is why those assertions are shaped the way they are: the flag was first applied to the wrong branch — the text depth slices instead of the geometric faces — by a replace that matched the first occurrence. It typechecked, every existing test passed, and the render gate stayed green. Only asserting that the front face and the slices stay two-sided caught it.

Fixed 2026-08-12 — a rounded card's front face floated inside its own outline#

buildSnapshot computed its front-face inset as clampBevel(w, h, d, request) for any rect and shrank the emitted front face by it. But the rounded branch of extrusionFaces returns before the bevel path and emits no chamfer ring — so a rounded card with bevelDepth: 12 drew a front face 24 px narrower than its own outline, meeting a ring that did not exist, and the darker back cap showed through the ring-shaped gap all the way around.

The root cause was a contract, not arithmetic. extrusion.ts recorded the deliberate choice to ignore a bevel on a rounded outline — a bevel on a rounded corner is a torus section, which a flat-quad wall model cannot express, so the rounded body is emitted un-bevelled rather than silently square-cornered — and never told the caller.

So the geometry now REPORTS what it emitted: extrusionGeometry returns { faces, bevel } and extrusionFaces is a thin reader over it. The bevel and the faces come from the same decision and cannot drift. Explicitly not fixed by giving buildSnapshot a "is this shape rounded?" predicate — that is the same coupling again, with a second copy of the branch logic to keep in step.

Fixed 2026-08-12 — deep extruded text combed instead of extruding#

Text and complex shapes extrude as a stack of thin plates. The slice count was capped at 45 with a 1.5 px step, and the shape of that bug is not what the code suggests: the stack always spanned the full depth, because sliceStep is extrusionDepth / sliceCount. Nothing was truncated. What saturated was DENSITY — past 45 × 1.5 = 67.5 px the same 45 plates simply moved apart:

depth  40   → 27 slices, 1.48 px apart
depth  67.5 → 45 slices, 1.50 px      ← the ceiling binds
depth 300   → 45 slices, 6.67 px      ← 4.3 px gaps at 40° yaw

Worth stating plainly because a brief written against this described the fix as "scale stepPx with depth so 45 slices always span the full extrusion" — which is what the code already did. The real choice was raise-the-cap versus building wall geometry from the glyph outline.

Why the cap was 45: nothing records one. It entered as a bare literal in a bulk feat:updated commit. Measured now — a slice is a flat quad in the shared depth pass with no offscreen resolve, and all slices of a layer share a contentHash and therefore one rasterized texture, so slices are far cheaper than the effect-laden faces faceEffects.ts has to budget:

slices    WebGL2    WebGPU
   45      471 ms    102 ms
  400      934 ms    154 ms

~1.3 ms per extra slice on WebGL2, ~0.15 ms on WebGPU. The cap is now 400, holding the intended 1.5 px spacing to 600 px of depth. Measured on the trailing-edge roughness of ext-text-depth-300: 1.193 px → 0.700 px, against 0.835 px for the depth-40 control — the deep body is now smoother than the shallow one.

Still an approximation of a solid by a stack of plates. The real fix is wall geometry from the glyph outline, as the rect path already does; this bounds the visible defect and the numbers above are what such a proposal should be measured against.

Fixed 2026-08-12 — an extruded solid could split across two render paths#

depthEligible3D is asked per RENDERABLE, but an extrusion is one OBJECT spread across up to fourteen of them. glass and backdropBlur are excluded from the depth group — correctly, they read what is composited beneath — and reached the front face and the back cap but not the four walls, because those two were built by spreading ...layer while the walls were constructed field-by-field. CompositionPass.renderList collects contiguous runs of eligible renderables, so the body went to the depth-tested group and the caps to the affine painter path, which has no depth state at all. The glass panel detached from the solid.

Fixed in two halves, because it is two problems:

  • The construction asymmetry. The backdrop-sampling fields are scrubbed from every synthesized face, on both the geometric and the slice paths. Glass was the observed case and was never the only candidate — ANY RenderLayer field outside the walls' explicit list reached the back cap alone.
  • The object-level disagreement. Scrubbing is only half an answer, because the front face IS the layer and legitimately keeps its glass. So enforceExtrusionPathAgreement (snapshot adapter) keeps every face of one object on whichever path the object as a whole takes, by asking the REAL depthEligible3D rather than restating its rules — a future exclusion added to that predicate is honoured automatically, which is the property the glass case did not have. It only ever moves an object OUT of the depth group: glass genuinely cannot be depth-tested, so the fix is to stop the body splitting, not to pretend the exclusion was wrong.

Guarded by extrusionDepthGroupParity.test.ts — eleven assertions in the lightShaderParity.test.ts idiom, including that the resolution moves toward the painter path and that one glass extrusion does not exempt an unrelated one beside it. Three of them failed before the fix. It also recurses into sealed precomps, where a body coming apart is exactly where nobody would look.


6. Where to look#

Concern Path
Effects registry, time-dependent set src/core/effects/effects.ts
Blend modes → shader selector src/core/effects/blendMode.ts
Per-frame resolution, DOF, shading, content hash src/core/rendering/buildSnapshot.ts
Render passes packages/renderer/src/rendergraph/passes/
Backend selection src/core/rendering/createRenderBackend.ts
Shape operator chain src/core/scene/pathOps.ts
Expressions language packages/animation/src/expressions.ts
Rig (IK/FABRIK/ARAP) src/core/rig/
Particles src/core/particles/particleSim.ts
Export, offline render loop src/core/export/
Canvas tools packages/workspace/src/tools/builtin.ts
AI tool registry packages/ai-tools/src/
Plugins docs/PLUGINS.md + src/core/plugins/

The commit bodies are this repo's delta ledger. When a .md and the code disagree, the code wins — and then the .md gets fixed.

Other docs#

PLUGINS.md (reasoning), PLUGIN_SYSTEM_REFERENCE.md (current state) and PLUGIN_SYSTEM_FOR_AI.md (condensed agent map) are a deliberate three-tier split of the actively-developed plugin system and are current. 3d-layer-model.md, AI_ARCHITECTURE_FULL.md, ANIMATED_SVG_PIPELINE.md and BONE_AND_PUPPET_RIGGING.md are subsystem deep-dives. COMPOSITING_PLAN.md is a historical delivery ledger, retained only because four source files cite its F-numbers — it is not a statement of current state.


Corrected 2026-08-17 — seven features shipped, and three walls behind them#

A session opened by asking whether an AE-parity assessment was accurate. It was not, and the reason is this document's oldest failure mode: the assessment had been written from README.md and ROADMAP.md, whose effect count was still the superseded 154, and whose preset figure — 39 — is a number no registry derives at all. Seven of the "missing" features it listed already shipped. §0's rule earned its keep again: a number in §1 is under test, a sentence anywhere is a claim.

(Phrased around those numbers rather than before them, because the propagation guard flagged the first draft of this paragraph — exactly as it flagged the entry that introduced it. Intended behaviour, and worth leaving visible twice.)

So the entries below exist to keep this file from doing the same thing to the next reader.

§4 said Now
"no named easing-preset registry" ShipseasePresets.ts, 8 families × 3 directions, through the existing applyEasingToKeyframes entry point
"the frame cache is memory-only… diskCache is zero hits" A disk tier ships (frameDiskCache.ts), session-scoped by necessity — see below
Essential Properties "still missing: non-numeric overrides" text / fill / color ship, with colour's three animated channels suppressed
Onion skinning absent Ships (onionSkin.ts + painter), paused-only
Cloners absent Ship (cloner.ts), three modes, Step + Random effectors, order- and layer-driven falloff
Rigid-body physics absent Ships (rigidBody.ts) on SimulationCache, hand-written — see below

Three things are blocked on a subsystem, not on effort. Each was scoped this session and each turned out to be a rewrite of a path rather than a feature behind it. Recording them so the next plan does not budget them as small:

  1. Cross-session frame caching needs a CONTENT-DERIVED invalidation key. The current one is built from sceneRevision and the animation revision — monotonic counters that reset to 0 every launch, so a persisted frame is indistinguishable from a different project's. Worth doing anyway: it would also stop an undo from clearing a cache whose pixels are identical to what it already held.
  2. Variable font axes remain unreachable, exactly as the 2026-08-12 entry found — and re-confirmed here rather than re-litigated. ctx.font is a CSS shorthand with no font-variation-settings, so this is a different text rasterization path (glyph shaping carrying variation coordinates), the same prerequisite outline-based extrusion wants.
  3. Cloning along a path hits the ordering: pathPoints is resolved deep inside buildSnapshot's per-layer loop, long after expandCloners runs. A second copy of that resolution at expansion time is precisely the drift this document exists to prevent, so path mode wants the resolution moved, not duplicated.

Physics is hand-written, and that was a decision rather than an oversight. Rapier arrives as WASM, which here means loosening the CSP to allow wasm-unsafe-eval — a security-policy change, made for a falling-box effect, that then applies to every page the renderer loads. It also adds a second determinism story beside SimulationCache's strict one. The honest limit of the result is stated in its own header and in the panel: bodies translate but do not spin, so colliders are axis-aligned. Joints, ragdolls or continuous collision are the point at which to revisit the dependency — with a concrete need, not in advance.

What none of this had: visual verification. Every feature above is covered by tests and by wiring guards, and none of it was seen running. The automation browser pane loads the app with document.hidden === true, so requestAnimationFrame never fires (measured: 0 callbacks in 2s) and the viewport render loop — which WorkspaceController.requestRender() schedules through rAF — never ticks. Anything downstream of a rendered frame therefore stays empty there: the RAM cache, the disk tier, the onion skins, the cloner. That is an environment limit rather than a finding about the code, but it is the reason these seven land with a caveat instead of a screenshot.


Corrected 2026-08-18 — two of yesterday's three walls fell on inspection#

Yesterday's entry named three subsystem walls. Re-examined rather than re-cited, two of them were not walls:

Yesterday said Today
Cross-session frame caching needs a content-derived key first The key landed same-day; retention landed today (frameDiskCache.ts): generations are PARKED, not deleted — an undo gets its frames back with zero re-renders, and a manifest lets a restart inherit the previous session warm
Cloning along a path "wants the resolution moved, not duplicated" It wanted neither — the resolution already existed as nodeWorldPolygon for boolean ops. nodeWorldOutline was EXTRACTED from it (open-path support added), and the cloner's path mode resolves through the same function the booleans use. The wall was a failure to find prior art, recorded as such
Variable font axes are a text-rasterization subsystem Still true. Re-confirmed, not re-attempted

Also superseding yesterday's physics caveat: rotation ships (rigidBody.ts), opt-in per body. The compat mechanism is structural — a locked body carries invInertia = 0, every angular term vanishes, and the algebra reduces exactly to the translation-only solver, so pre-rotation scenes replay bit-identically. With Spin on: real OBBs (SAT + clipped two-point manifold), impulses at the contact point, circles that roll from friction alone. Three solver bugs were caught by tests before shipping — sequential per-point impulses spinning symmetric hits, manifold depth measured from the reference centre instead of its face, and a wall-contact tie window that swallowed sub-pixel contacts.

And closing a dead export the working agreements flag: FrameDiskCache.purge() now has its one caller — a Preview-disk-cache row in Customize ▸ Appearance with a size readout (parked generations included) and a Purge button. Output templates (above) closed the render-settings gap the same day.

Corrected 2026-08-19 — the decoder wall fell; the fixture corrected the test#

The last standing Tier-1 blocker — "a real VideoDecoder needs a container demuxer; a subsystem, not a change" — is no longer a wall. src/core/video/:

  • mp4Demuxer.ts — mp4box (new dependency, pure JS, no WASM — the CSP objection that ruled out Rapier does not apply, and pure-JS is why the demux runs in jest against real ffmpeg fixtures rather than being taken on faith). Out: WebCodecs codec string, avcC/hvcC description (box header stripped), sample table in decode order.
  • frameIndex.ts — the pure arithmetic of random access: presentation order from cts (B-delay normalized away), GOP keyframe per frame, and the feed-through index (running max of decode index — a B-frame needs the FUTURE reference that sits earlier in decode order).
  • exactVideoSource.ts — the session: feed [key .. feed-through], flush (which both forces emission and legally resets to needing a key — random access and flush-per-request are the same design), cache every frame the flush emits so stepping forward is cache hits. DecoderIO seam because jsdom has no WebCodecs — the fake pins the full feeding discipline.

Two fixture-taught facts worth keeping: ffmpeg parks the B-frame delay in the container (cts starts at 1024, not 0 — normalization is load-bearing), and it pads the LAST sample's duration by that same delay (the duration test asserted 800000µs from arithmetic; the file said 833334 and the file was right).

First consumer: footage preview's Frame-by-frame mode (videoRef hands the pause point to the exact path, so stepping starts where you were looking).

Corrected again 2026-08-19 — the renderer swap landed#

"NOT yet consumed by: the renderer, export, tracking" outlived its truth the same day it was written. src/core/rendering/exactVideoFrames.ts is the renderer's exact tier: same synchronous get() + AnimationChanged-repaint contract as videoFrameCache, backed by demuxMp4ExactVideoSource, serving canvases keyed by PRESENTATION INDEX (the upload signature, so a repeated render never re-uploads). MotionRendererBackend.feedVideoFrame asks exact → legacy seek cache (frame blend only) → live element seek, and releases any stale frame entry before an element fallback because frame entries shadow video entries in getTexture. Export exactness: the cache's inflight loads/decodes are merged into takeMediaWaits, so the existing convergence loop settles onto exact frames — an exact: false nearest-neighbour can paint the viewport for a tick but can never ship in an export. Fallback is sticky per source (unavailable): WebM/odd-MOV, files over the in-memory demux cap, unsupported codecs and WebCodecs-less runtimes stay on the element path for the whole session, because flapping between exact and approximate frames on one source looks like a bug.

One boundary fact worth keeping (it cost a test failure to learn): frame starts in the index are FRACTIONAL microseconds (cts/timescale × 1e6), while a rounded integer-µs query at an exact frame boundary (t = N/fps) lands just below them and resolves the previous frame. The cache biases its query +1µs — three orders of magnitude under any frame duration, exactly enough to make the timeline's own playhead times resolve the frame they name.

Tracking landed the next day (2026-08-20): src/core/tracking/ — Track Motion on video layers. Three-layer split: patchMatch.ts (pure matcher: zero-mean NCC over an integer search window, then Lucas-Kanade gradient refinement because a parabolic peak fit alone pixel-locks and a CHAINED tracker integrates that bias into a walk-off), tracker.ts (policy: chained reference for appearance evolution + frame-0 anchor template for drift correction — the standard template-update-problem answer — plus velocity prediction and occlusion coasting), trackVideoLayer.ts (the seam: comp frame → compToKeyframeTime → media seconds → exact decoder presentation index, so the frame matched is the frame rendered). Apply writes x/y keyframes through the video layer's live transform per sample time (applyTrack.ts), spliced like Motion Sketch, one runAnimEdit undo step. UI: TrackMotionSection (video-layer inspector) + TrackPointOverlay (EffectHandleOverlay-pattern SVG, draggable point in SOURCE pixels — see trackerSource.ts for the one grid rule). Verified end-to-end in a real Chromium tab against a synthetic ffmpeg clip with known motion: 300 comp frames tracked at confidence 1.00, end position within a pixel of ground truth, applied keyframes matching the hand-computed space chain exactly.

Found while wiring it: addAssetsBatch — the Assets panel's import path — never read video/audio element metadata (only single-file addAsset did), so every panel-imported video had no width/height/duration and footageSourceOf reported 0×0. Fixed in place; the tracker section was the first consumer strict enough to notice.

The rest of the column landed the same day: tracker.ts grew trackPoints (N points through ONE decode walk — each frame decoded once, every live point matches against it; a lost point's track ends, the others walk on), trackVideoLayerPoints drives it, and three more apply paths joined applyTrackToLayer:

  • Stabilize (applyStabilizeToLayer): inverse motion on the video layer itself, planned entirely against the ORIGINAL transform before writing (the planExpressionBake discipline), correction converted to parent space by differencing two fromComp points so a rotated parent bends the delta correctly. Verified live: after applying, the tracked feature projected to the SAME comp point at every sampled time — spread 0.0 in both axes.
  • Corner pin (applyCornerPinTrack): keyframes the target's Corner Pin EFFECT (top-left-origin OFFSET space against defaultCorners) — the effect is added with an EXPLICIT id before any effect.<id>.<param> track is written (the addEffect-returns-void trap is documented at its declaration), tracks created via setKeyframes directly because writeEffectParams only keyframes params whose stopwatch is already on. Verified live: 4 corners × 300 frames at confidence 1.00, 8 params keyframed, quad stayed a rigid 24×24 square riding the target.
  • Track mask (maskTrack.ts): every vertex of the layer's mask becomes a track point (one walk), and the result is written as maskAnim keyframes through the EXISTING snapshot system (readNodeMaskAt is the renderer's only entry), spliced so keyframes outside the tracked range survive. Bezier handles travel rigidly with their vertex; a lost vertex freezes (index-paired interpolation needs every keyframe to carry every point). Like every other mask write, NOT in the undo history — mask state lives on fx props, outside the animation diff. Verified live: 4 vertices × 300 keyframes, displacement within a source pixel of ground truth.

UI: TrackMotionSection gained a mode select (Follow / Stabilize / Corner pin / Track mask); TrackPointOverlay draws however many points the mode carries (corner mode: labelled TL/TR/BR/BL plus the quad outline).

Still open on this column: a roto brush / edge-aware masks, 2-point rotation+scale solves, and setVideoBaked (Canvas2D-bake path) still seeks the element. Verification: decode discipline and cache policy are test-pinned, AND the real-machine pass ran 2026-08-19 in a real Chromium tab — frame-by-frame mode stepped tiny-ipp.mp4 with actual pixel readback from the dialog canvas (non-blank, content drifting across frames, timestamps exact), and after Add-at-Playhead the render cache reported ready with all 24 frames decoded (294,912 bytes — exactly 24 × 64×48×4) and zero console errors.

Corrected 2026-09-02 — "out of scope by direction" was reversed#

§4 claimed Reality
"No imported 3D models, PBR or HDRI… Out of scope by direction" Reversed by the user, and shipped 2026-09-01/02. glTF .glb/embedded .gltf import lands as ordinary layers (null per node, mesh layer per primitive) through the extrusion mesh render path — modelImport.ts — with CPU skinning against joint layers (modelSkinning.ts), morph-target blend shapes (modelMorph.ts), baked animation clips (modelAnimation.ts), and 3D IK / CCD over joint chains (boneIK3d.ts). Environment Light (environmentLight.ts) shipped alongside as an SH irradiance probe expressed through the existing light array — zero renderer changes, no reflections. Still out of scope: HDRI file import, a reflection map, PBR texture maps beyond base colour, external-file .gltf

docs/3d-layer-model.md's opening paragraph and its new "Imported models" section, and ROADMAP.md's "Advanced 3D" entry, were both restating the old "explicitly out of scope" claim as of this pass and are corrected in the same commit.

Corrected 2026-09-02 (second pass) — three tiers landed in one day, and §4 was a day behind#

Three commits shipped between the entry above and this one, and every §4 gap they closed was still written here as open. Recording the corrections rather than silently editing them out, because two of them are the failure mode §0 names exactly: a limit stated in prose, fixed in code the same week, and never re-read — the frame-cache row asserted a constraint the file it cites had already spent thirty lines explaining it no longer had.

§4 said Reality, verified against the source named
Render queue "Stop discards progress; real pause/resume is unbuilt" Shipped (renderQueueStore.ts, renderQueuePauseResume.test.ts). Abort is treated as pause, the sink is kept, resumeFrame survives; Discard is the separate destructive verb. Session-scoped, because the resume handle is a live sink and is never serialized
The frame disk cache is "session-scoped on purpose… surviving a restart needs a CONTENT-DERIVED key first" Both halves landed 2026-08-17/18 and this row was thirteen days stale. sceneContentHash.ts supplies the key; retention parks dead generations and a manifest reconciles them at open, so an undo and a restart both come back warm. The header of frameDiskCache.ts says so in full
"No local project browser in the OSS edition"; LocalIndex has no implementation and better-sqlite3 "is absent from package.json" Shipped 2026-08-20. The driver is in optionalDependencies, src/core/localIndex/indexWriter.ts is the writer that never existed, and the start screen is a card grid over it. What is genuinely owed is a real-device electron-rebuild pass — a narrower claim than the one that stood here
"There is still no DOF pass in packages/renderer" Retired 2026-09-01 in the row above, and still restated here. Two shaders ship. The honest gap is now the cross-layer depth gather, not the pass
The 3D lighting entry's absence greps: "no normal maps… envMap / roughness / metalness / hdri / pbr: zero", and "environmentLight and imageBased are zero hits, so there is no image-based lighting" Superseded on imported geometry. glTF normal / metallic-roughness / occlusion / emissive maps ship on a mesh3d-pbr material; IBL ships in both halves — SH irradiance and a prefiltered specular atlas with split-sum reflections. The claim survives only for ordinary 2D and extruded layers, whose normal is still a constant per renderable, and it is now stated that way
Imported models: "still out of scope: HDRI file import, a reflection/specular map, PBR texture maps beyond base colour, and external-file .gltf" All four closed 2026-09-02, along with real curved primitives. Shadow maps then shipped the same day (opt-in per light, PCF, byte-identical when off); SSAO is blocked by the multisampled scene targets (no sampleable depth on either backend) and height displacement is not started
§3 Compositing: "36 layer blend modes" 38, and the same document's own Tier-2 entry already said "all 38 of AE's 38". The phrase escaped docPropagatedCounts.test.ts because the word between the digit and the noun ("36 layer blend modes") breaks its adjacency rule — the guard is narrow on purpose, and this is the cost. Rewritten into the checkable form
§3 Import/export: "Nine export formats", against §1's 18 The list was of RENDERED formats only; exportFormats unions VideoFormat with ExportFormat and includes the HDR delivery variants, EXR, WAV and the interchange writers. Rewritten to state 18 and enumerate what the other nine are, so the two halves of this document stop disagreeing
README.md: the assistant has "62 tools" 65, and it is the SAME retired claim the §5 table at the top of this section already carries as "62 AI tools" → 65. It survived in the most-read file in the repo for the same reason "36 layer blend modes" did — it is not written as N AI tools, so nothing checked it. Corrected, and rewritten into the guarded form. AE_COMPARISON.md held a third number (61, with a craft 17 breakdown that is now 21)

No 3D gizmo snapping is still true and stays. It was re-checked this pass rather than assumed: gizmo3dSnapping remains deleted with its nine siblings, deadLayoutState.test.ts still keeps it deleted, and nothing in the day's three commits adds it. Clip-edge snapping and smart guides are timeline and 2D-canvas features and are not this.

Particle density is still the ceiling it was. Bake-to-layers shipped, which makes a simulation art-directable, but turbulence, particle–particle collisions, sub-emitters, trails, 3D particles and layer-as-particle remain absent.

Updated

Was this page helpful?