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 --verbosesrc/__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.ts → EffectType |
| Blend modes | 38 | src/core/effects/blendMode.ts → LayerBlendMode |
| Layer styles | 10 | layerStyles.ts → LAYER_STYLE_LABEL + BACKDROP_STYLES |
| Path operators | 9 | src/core/scene/pathOps.ts → PathOpType (less none) |
| Mask modes | 7 | src/core/effects/mask.ts → MaskMode |
| Light types | 5 | src/core/scene/light.ts → LightType |
| 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.ts → VideoFormat + exportManager.ts → ExportFormat |
| 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.ts → modifierCompile.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.ts → buildSnapshot →
MotionRendererBackend → AppTextureProvider, 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.ts → TimelineController →
ResponsiveTimeSection.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 only — aiEnabled() 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 existingstartFrame/endFramerange, 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 aresumeFrame; 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-sqlite3is inoptionalDependencies;src/core/localIndex/indexWriter.tsis the writer the index never had (saves and opens write it, and a thumbnail worker fills thethumbHashcolumn 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 anelectron-rebuildagainst the Electron ABI, and until that runsindex:availablecan still be false on a given machine andgetLocalIndex()fall back toMemoryLocalIndex. - 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" (__essentialPropson 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,fillandcolorjoin the numeric set viaOVERRIDE_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.tsmade the key name a scene rather than merely notice that one changed; then RETENTION inframeDiskCache.tsparks 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
.motionformat 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#
- Linear-light colour — shipped 2026-08-14. Grade / blend / blur run in
linear under
LINEAR_WORKING_SPACE; RTs stay linear untilscene-blit(LINEAR_INTERMEDIATE_STORAGE); uploads tagged display-referred (displayReferred+ optionalHARDWARE_SRGB_UPLOADS). Project settings (working space, ACES ODT, 16/32-bit intermediates) live in Composition Settings → Color. Remaining: HDR output. - Particle system — stateful floor bounce shipped 2026-08-14. Opt-in
simMode: 'stateful'withSimulationCache. Remaining density: turbulence, collisions, trails, sub-emitters, 3D / layer-as-particle. - Depth of field — corner CoC + polygonal bokeh shipped 2026-09-01.
coc-blurvaries the blur radius per pixel across a quad from four corner radii (planDofCocCorners),bokehgathers a polygonal iris, both inbuiltin.tsand dispatched byCompositionPass; 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. - 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.
- 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 byshadeLayerand absent fromShaderLight, 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.tsnow 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_ATTACHMENTand noTEXTURE_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:
- static —
expandCompInstancespatches the clone's components. - animated —
buildSnapshot'sanimshim 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:
materializeForFrameis a whitelist.__overriddenPropswas dropped in transit until it was named there, exactly as__instanceSourcemust be.- 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.compOverrideshanded down, replacing rather than inheriting through...compso an outer instance cannot leak into a nested one. - The prop is not always on the Transform.
readBaseis last-write-wins andopacitylives 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 | 4 — equalize, 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/dyas a neighbourhood signal classified Vignette — a per-pixel radial falloff — as neighbourhood sampling, sincedxis 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) countspixelmatchdiffs:ratio = diffPixels / totalPixels, flagging any pixel past a small numeric threshold, sub-perceptual ones included. analyze-gap.mjscounts 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
Renderableas having four fields.indexOf('export interface Renderable')matchedRenderableSdf, 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
depthExemptas 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 tocountUserPresets(), which sharesreadUserPresetswith 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.tsand all twelveabs(dot(…))sites inbuiltin.tscannot 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
RenderLayerfield 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 REALdepthEligible3Drather 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" | Ships — easePresets.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:
- Cross-session frame caching needs a CONTENT-DERIVED invalidation key. The
current one is built from
sceneRevisionand 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. - Variable font axes remain unreachable, exactly as the 2026-08-12 entry
found — and re-confirmed here rather than re-litigated.
ctx.fontis a CSS shorthand with nofont-variation-settings, so this is a different text rasterization path (glyph shaping carrying variation coordinates), the same prerequisite outline-based extrusion wants. - Cloning along a path hits the ordering:
pathPointsis resolved deep insidebuildSnapshot's per-layer loop, long afterexpandClonersruns. 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.DecoderIOseam 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 demuxMp4 → ExactVideoSource,
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 twofromComppoints 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 againstdefaultCorners) — the effect is added with an EXPLICIT id before anyeffect.<id>.<param>track is written (the addEffect-returns-void trap is documented at its declaration), tracks created viasetKeyframesdirectly becausewriteEffectParamsonly 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 asmaskAnimkeyframes through the EXISTING snapshot system (readNodeMaskAtis 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.