arthur/frontend/README.md

117 lines
4.8 KiB
Markdown
Raw Normal View History

Port steps 0-1: scaffold, the oracle, and the pure bottom Scaffolds frontend/ (shadow-cljs, reagent 1.2.0, re-frame 1.4.3) and ports everything below the data model, with the JS kept as a numeric oracle. domain/landmarks index tables, verbatim domain/ring subsample, offset, simplicity domain/geom similarity fit, procrustes, moving average domain/raster indexed scanline fill, stencil, disc, rect domain/palette the ramp, and the no-sampled-RGB rule 58 tests, 166 assertions. Parity with js/ on the identical 72-frame synthetic track: fit-similarity, procrustes-mean, fit-residual, moving-average, smooth-transforms, offset-ring and subsample-slots to 1e-9; the raster pixel-for-pixel over the whole buffer. Three deviations from the JS, each for a reason: - synth.cljs jitters from a SEEDED generator, not Math.random. Parity is only checkable if both sides can be handed the same track, and a failing assertion has to be reproducible. `:rand-fn` takes the generator over, so oracle.mjs stubs js/Math.random and js/ itself stays untouched. - raster/->rgba replaces toImageData. ImageData is a DOM type and domain/ may not touch the DOM; returning plain bytes also lets the no-intermediate-colours assertion run in node. ui/canvas wraps it later. - offset-ring lives in domain/ring, not domain/geom, per architecture.md: it is an operation on an ordered traversal, not on a transform. Step 1's "done" also names the swapped-iris vote, but pairIrises is in pipeline.js and belongs to step 7. The precondition is asserted instead -- `:swap-iris` really does move both blocks -- so the vote will have a track that disagrees with it when it arrives. One finding, recorded in full in the test that measures it: smooth-transforms buys nothing on the synthetic track. Against jitter-free ground truth, radius 1 helps by 17% on one noise realisation and hurts by 0.5% on another, so its benefit is within noise; from radius 2 up the cost is unambiguous, and by radius 5 the filter is below the true motion's own high-frequency energy, i.e. smoothing away performance. The test pins the shape of the knob rather than a preferred value. This may say more about the synth's jitter being unrealistically small (+/-0.001 normalised) than about the knob; step 6 settles it on real footage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 14:43:34 -04:00
# frontend
The ClojureScript half. See `docs/port-plan.md` for what is being built and in
what order; this file is only how to run it.
## Once
```sh
mise install # from the REPO ROOT: java 21+, node 20, clojure, python
cd frontend && npm install
```
`java` must be 21+. On an older JDK shadow-cljs fails with "CompilerOptions has
been compiled by a more recent version of the Java Runtime", which reads like a
shadow-cljs bug and is not one. `mise install` is what prevents it.
## The tests
```sh
cd frontend && npm test
```
That is three things in order: regenerate the JS oracle's answers, compile the
`:test` build, run it under node.
```
node test/parity/oracle.mjs # drives js/ and writes test/parity/oracle.json
shadow-cljs compile test
node out/node-tests.js
```
Run them separately if a compile error is in the way. The oracle JSON is
generated, not committed.
Port steps 2-3: the data model and the player Steps 2 and 3 land together because the model revisions in the middle changed code from both, and splitting them now would invent intermediate states that never built. domain/channel value-at across framed/keyed/dense, plus a cursor domain/node decomposed transform, composition order, time maps domain/scene topological order, z paths, eval-frame and resolver clock audio-clocked frame derivation, outside app-db db/events/subs re-frame arrives; the playhead is document state ui/player the rAF loop; reads, blits, dispatches (almost) nothing ui/shell transport 133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and runs at 1/4x through 4x; verified by driving a real browser over CDP rather than by assertion. Two evaluators, on purpose. `eval-frame` is the specification -- allocating, order-free, obviously correct. `resolver` is what playback uses: cached topo order and z paths, a cursor per channel, a preallocated point buffer per node. Both run the same walk, parameterised only by how a channel is read and where points are written, because two independent implementations of frame evaluation would drift and the drift would read as a rendering bug rather than as two functions disagreeing. scene-test asserts they agree frame for frame in forward, backward and random order. Deviations and decisions, each with a reason: - raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat preallocated buffer. ONE scanline fill serves the analysis stages, which speak {:x :y}, and frame evaluation, which hands over a buffer it owns. The parity suite still passes pixel-for-pixel, which is what makes the rewrite safe. - The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too, per architecture.md's "hidden flag + palette index", and that bit was a dense [:vis] wearing a different hat -- two mechanisms for one question, which is how a part ends up hidden by one and shown by the other. - The palette is a parameter of evaluation, not a global. A node names a TONE; which ramp that tone is read in belongs to the timeline it sits in. - :over layers and a symbol :rate THROW rather than being ignored. Neither is built and nothing can produce one, so this can only fire on data that has run ahead of the code. A silently dropped override is a hand correction the user made once, watched fail, and has no reason to trust again. Three findings the model produced rather than received: - Presence propagates asymmetrically. An absent transform drops the subtree; an absent [:geom :pts] drops only that node, because an absent mouth outline has nothing to draw but the head it hangs off has not moved. That asymmetry is the reason presence is tracked per channel and not per node. - Z paths need lexicographic compare, not `compare`, which orders vectors by count first -- so a cel three levels under "a1" would jump in front of a bare "a2" and the layer order would mostly work. - A node stencilled by something that drew nothing is dropped, not drawn unclipped: an iris floating over the cheek is worse than a missing iris. docs/ revised alongside, and those revisions are the load-bearing part: - A scene, a timeline and a symbol are one type. The doc had two structures with the same fields and never said so. Two axes of nesting are now separated -- parent/child within a timeline is flat with parent pointers, instance nesting is by reference -- which is why "nestable" and "flat" only sounded contradictory. - Palettes are named, live on the project, and are ENABLED on a timeline as a channel. Absent inherits; present travels with the timeline, so a symbol authored against :night stays night wherever it is placed. The output index space is the concatenation of the named ramps, which keeps one buffer and one flat table and incidentally stops two nodes in different palettes colliding on a stencil. - Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so the normalise on/off/per-plate toggle is which of the three channel shapes the :head node carries. Always measure and always store factored -- smoothing and velocity-minimum key selection both need the split to exist in storage. - There is no camera node and none is needed. Placement is a node transform, the stage clips what hangs off it, and project dimensions are independent of the footage. `makeXform` is therefore not to be ported: it bakes a cropping decision into every stored vertex. - Export is removed. The .take writer was for an Animator Pro render script; the target is encoding video in the browser, and step 9 now says not to port the old one. demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store handles -- the shape freeze produces at step 5, and the first thing to exercise that path under load. It plays at 30fps, and bench-test keeps a deliberately loose floor under it because a performance regression here does not announce itself: the picture stays correct and merely arrives late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
2026-09-27 17:28:05 -04:00
**Run them through `mise`**, or make sure `mise`'s node is first on PATH. The
oracle imports `js/*.js` directly, and those are ES modules in a directory with
no `package.json`, so node needs the module detection that became default in
20.19. On an older node 20 — an nvm install shadowing the pinned one is the easy
way to get there — every import fails with "Named export not found ... is a
CommonJS module", which reads like the oracle being broken and is not.
Port steps 0-1: scaffold, the oracle, and the pure bottom Scaffolds frontend/ (shadow-cljs, reagent 1.2.0, re-frame 1.4.3) and ports everything below the data model, with the JS kept as a numeric oracle. domain/landmarks index tables, verbatim domain/ring subsample, offset, simplicity domain/geom similarity fit, procrustes, moving average domain/raster indexed scanline fill, stencil, disc, rect domain/palette the ramp, and the no-sampled-RGB rule 58 tests, 166 assertions. Parity with js/ on the identical 72-frame synthetic track: fit-similarity, procrustes-mean, fit-residual, moving-average, smooth-transforms, offset-ring and subsample-slots to 1e-9; the raster pixel-for-pixel over the whole buffer. Three deviations from the JS, each for a reason: - synth.cljs jitters from a SEEDED generator, not Math.random. Parity is only checkable if both sides can be handed the same track, and a failing assertion has to be reproducible. `:rand-fn` takes the generator over, so oracle.mjs stubs js/Math.random and js/ itself stays untouched. - raster/->rgba replaces toImageData. ImageData is a DOM type and domain/ may not touch the DOM; returning plain bytes also lets the no-intermediate-colours assertion run in node. ui/canvas wraps it later. - offset-ring lives in domain/ring, not domain/geom, per architecture.md: it is an operation on an ordered traversal, not on a transform. Step 1's "done" also names the swapped-iris vote, but pairIrises is in pipeline.js and belongs to step 7. The precondition is asserted instead -- `:swap-iris` really does move both blocks -- so the vote will have a track that disagrees with it when it arrives. One finding, recorded in full in the test that measures it: smooth-transforms buys nothing on the synthetic track. Against jitter-free ground truth, radius 1 helps by 17% on one noise realisation and hurts by 0.5% on another, so its benefit is within noise; from radius 2 up the cost is unambiguous, and by radius 5 the filter is below the true motion's own high-frequency energy, i.e. smoothing away performance. The test pins the shape of the knob rather than a preferred value. This may say more about the synth's jitter being unrealistically small (+/-0.001 normalised) than about the knob; step 6 settles it on real footage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 14:43:34 -04:00
## The app
```sh
cd frontend && npx shadow-cljs watch app
```
Then open **<http://localhost:8778/index.html>** — with the `/index.html`, not
bare `/`. This shadow-cljs does no directory-index resolution, so `/` is a 404
whatever the roots are.
Port steps 2-3: the data model and the player Steps 2 and 3 land together because the model revisions in the middle changed code from both, and splitting them now would invent intermediate states that never built. domain/channel value-at across framed/keyed/dense, plus a cursor domain/node decomposed transform, composition order, time maps domain/scene topological order, z paths, eval-frame and resolver clock audio-clocked frame derivation, outside app-db db/events/subs re-frame arrives; the playhead is document state ui/player the rAF loop; reads, blits, dispatches (almost) nothing ui/shell transport 133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and runs at 1/4x through 4x; verified by driving a real browser over CDP rather than by assertion. Two evaluators, on purpose. `eval-frame` is the specification -- allocating, order-free, obviously correct. `resolver` is what playback uses: cached topo order and z paths, a cursor per channel, a preallocated point buffer per node. Both run the same walk, parameterised only by how a channel is read and where points are written, because two independent implementations of frame evaluation would drift and the drift would read as a rendering bug rather than as two functions disagreeing. scene-test asserts they agree frame for frame in forward, backward and random order. Deviations and decisions, each with a reason: - raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat preallocated buffer. ONE scanline fill serves the analysis stages, which speak {:x :y}, and frame evaluation, which hands over a buffer it owns. The parity suite still passes pixel-for-pixel, which is what makes the rewrite safe. - The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too, per architecture.md's "hidden flag + palette index", and that bit was a dense [:vis] wearing a different hat -- two mechanisms for one question, which is how a part ends up hidden by one and shown by the other. - The palette is a parameter of evaluation, not a global. A node names a TONE; which ramp that tone is read in belongs to the timeline it sits in. - :over layers and a symbol :rate THROW rather than being ignored. Neither is built and nothing can produce one, so this can only fire on data that has run ahead of the code. A silently dropped override is a hand correction the user made once, watched fail, and has no reason to trust again. Three findings the model produced rather than received: - Presence propagates asymmetrically. An absent transform drops the subtree; an absent [:geom :pts] drops only that node, because an absent mouth outline has nothing to draw but the head it hangs off has not moved. That asymmetry is the reason presence is tracked per channel and not per node. - Z paths need lexicographic compare, not `compare`, which orders vectors by count first -- so a cel three levels under "a1" would jump in front of a bare "a2" and the layer order would mostly work. - A node stencilled by something that drew nothing is dropped, not drawn unclipped: an iris floating over the cheek is worse than a missing iris. docs/ revised alongside, and those revisions are the load-bearing part: - A scene, a timeline and a symbol are one type. The doc had two structures with the same fields and never said so. Two axes of nesting are now separated -- parent/child within a timeline is flat with parent pointers, instance nesting is by reference -- which is why "nestable" and "flat" only sounded contradictory. - Palettes are named, live on the project, and are ENABLED on a timeline as a channel. Absent inherits; present travels with the timeline, so a symbol authored against :night stays night wherever it is placed. The output index space is the concatenation of the named ramps, which keeps one buffer and one flat table and incidentally stops two nodes in different palettes colliding on a stencil. - Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so the normalise on/off/per-plate toggle is which of the three channel shapes the :head node carries. Always measure and always store factored -- smoothing and velocity-minimum key selection both need the split to exist in storage. - There is no camera node and none is needed. Placement is a node transform, the stage clips what hangs off it, and project dimensions are independent of the footage. `makeXform` is therefore not to be ported: it bakes a cropping decision into every stored vertex. - Export is removed. The .take writer was for an Animator Pro render script; the target is encoding video in the browser, and step 9 now says not to port the old one. demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store handles -- the shape freeze produces at step 5, and the first thing to exercise that path under load. It plays at 30fps, and bench-test keeps a deliberately loose floor under it because a performance regression here does not announce itself: the picture stays correct and merely arrives late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
2026-09-27 17:28:05 -04:00
The page is the hand-written scene from step 2, scrubbed by hand. There is
deliberately no clock: the audio clock, the rAF loop, the `::resolver`
subscription and the transport are step 3, and the question this step answers is
whether the data model evaluates correctly, not whether it evaluates at 30fps.
A scrubber answers the first and nothing else, which is what makes a failure
here unambiguous.
The scene itself is `src/arthur/demo/scene.edn`, and it is not a face. It is the
smallest scene that exercises every mechanism the model claims to have —
inherited exposure, a sparse held `[:xform :pos]`, composition through a group,
rotation about an anchor, a stencil chain, a keyed `[:vis]`, a `:span`, and
fractional z among siblings — chosen so that each one is visible when it breaks.
Port steps 0-1: scaffold, the oracle, and the pure bottom Scaffolds frontend/ (shadow-cljs, reagent 1.2.0, re-frame 1.4.3) and ports everything below the data model, with the JS kept as a numeric oracle. domain/landmarks index tables, verbatim domain/ring subsample, offset, simplicity domain/geom similarity fit, procrustes, moving average domain/raster indexed scanline fill, stencil, disc, rect domain/palette the ramp, and the no-sampled-RGB rule 58 tests, 166 assertions. Parity with js/ on the identical 72-frame synthetic track: fit-similarity, procrustes-mean, fit-residual, moving-average, smooth-transforms, offset-ring and subsample-slots to 1e-9; the raster pixel-for-pixel over the whole buffer. Three deviations from the JS, each for a reason: - synth.cljs jitters from a SEEDED generator, not Math.random. Parity is only checkable if both sides can be handed the same track, and a failing assertion has to be reproducible. `:rand-fn` takes the generator over, so oracle.mjs stubs js/Math.random and js/ itself stays untouched. - raster/->rgba replaces toImageData. ImageData is a DOM type and domain/ may not touch the DOM; returning plain bytes also lets the no-intermediate-colours assertion run in node. ui/canvas wraps it later. - offset-ring lives in domain/ring, not domain/geom, per architecture.md: it is an operation on an ordered traversal, not on a transform. Step 1's "done" also names the swapped-iris vote, but pairIrises is in pipeline.js and belongs to step 7. The precondition is asserted instead -- `:swap-iris` really does move both blocks -- so the vote will have a track that disagrees with it when it arrives. One finding, recorded in full in the test that measures it: smooth-transforms buys nothing on the synthetic track. Against jitter-free ground truth, radius 1 helps by 17% on one noise realisation and hurts by 0.5% on another, so its benefit is within noise; from radius 2 up the cost is unambiguous, and by radius 5 the filter is below the true motion's own high-frequency energy, i.e. smoothing away performance. The test pins the shape of the knob rather than a preferred value. This may say more about the synth's jitter being unrealistically small (+/-0.001 normalised) than about the knob; step 6 settles it on real footage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 14:43:34 -04:00
Port 8778 is deliberately not 8777. `python3 serve.py` from the repo root still
runs the old JS tool on 8777, and the two are meant to run side by side — that is
the whole reason `js/` is still in the tree.
From step 9 Django serves the page and `:dev-http` goes away.
## The oracle
`js/` is the numeric oracle, not dead weight. `test/parity/` runs both
implementations on the same synthetic track and diffs them: `fit-similarity` and
`procrustes-mean` agree to 1e-9, the raster pixel-for-pixel.
Both sides get the identical track because `js/synth.js` reads `Math.random` at
call time, so `oracle.mjs` stubs it to a constant and the CLJS side passes
`:rand-fn (constantly 0.5)`. `js/` itself is never modified.
**`test/parity/` and `arthur.parity-test` get deleted in one commit at step 5.**
A parity test pins behaviour while code moves; keeping it afterwards would bake
the prototype's mistakes into the rewrite and make them permanent.
## Layout
```
Port steps 2-3: the data model and the player Steps 2 and 3 land together because the model revisions in the middle changed code from both, and splitting them now would invent intermediate states that never built. domain/channel value-at across framed/keyed/dense, plus a cursor domain/node decomposed transform, composition order, time maps domain/scene topological order, z paths, eval-frame and resolver clock audio-clocked frame derivation, outside app-db db/events/subs re-frame arrives; the playhead is document state ui/player the rAF loop; reads, blits, dispatches (almost) nothing ui/shell transport 133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and runs at 1/4x through 4x; verified by driving a real browser over CDP rather than by assertion. Two evaluators, on purpose. `eval-frame` is the specification -- allocating, order-free, obviously correct. `resolver` is what playback uses: cached topo order and z paths, a cursor per channel, a preallocated point buffer per node. Both run the same walk, parameterised only by how a channel is read and where points are written, because two independent implementations of frame evaluation would drift and the drift would read as a rendering bug rather than as two functions disagreeing. scene-test asserts they agree frame for frame in forward, backward and random order. Deviations and decisions, each with a reason: - raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat preallocated buffer. ONE scanline fill serves the analysis stages, which speak {:x :y}, and frame evaluation, which hands over a buffer it owns. The parity suite still passes pixel-for-pixel, which is what makes the rewrite safe. - The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too, per architecture.md's "hidden flag + palette index", and that bit was a dense [:vis] wearing a different hat -- two mechanisms for one question, which is how a part ends up hidden by one and shown by the other. - The palette is a parameter of evaluation, not a global. A node names a TONE; which ramp that tone is read in belongs to the timeline it sits in. - :over layers and a symbol :rate THROW rather than being ignored. Neither is built and nothing can produce one, so this can only fire on data that has run ahead of the code. A silently dropped override is a hand correction the user made once, watched fail, and has no reason to trust again. Three findings the model produced rather than received: - Presence propagates asymmetrically. An absent transform drops the subtree; an absent [:geom :pts] drops only that node, because an absent mouth outline has nothing to draw but the head it hangs off has not moved. That asymmetry is the reason presence is tracked per channel and not per node. - Z paths need lexicographic compare, not `compare`, which orders vectors by count first -- so a cel three levels under "a1" would jump in front of a bare "a2" and the layer order would mostly work. - A node stencilled by something that drew nothing is dropped, not drawn unclipped: an iris floating over the cheek is worse than a missing iris. docs/ revised alongside, and those revisions are the load-bearing part: - A scene, a timeline and a symbol are one type. The doc had two structures with the same fields and never said so. Two axes of nesting are now separated -- parent/child within a timeline is flat with parent pointers, instance nesting is by reference -- which is why "nestable" and "flat" only sounded contradictory. - Palettes are named, live on the project, and are ENABLED on a timeline as a channel. Absent inherits; present travels with the timeline, so a symbol authored against :night stays night wherever it is placed. The output index space is the concatenation of the named ramps, which keeps one buffer and one flat table and incidentally stops two nodes in different palettes colliding on a stencil. - Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so the normalise on/off/per-plate toggle is which of the three channel shapes the :head node carries. Always measure and always store factored -- smoothing and velocity-minimum key selection both need the split to exist in storage. - There is no camera node and none is needed. Placement is a node transform, the stage clips what hangs off it, and project dimensions are independent of the footage. `makeXform` is therefore not to be ported: it bakes a cropping decision into every stored vertex. - Export is removed. The .take writer was for an Animator Pro render script; the target is encoding video in the browser, and step 9 now says not to port the old one. demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store handles -- the shape freeze produces at step 5, and the first thing to exercise that path under load. It plays at 30fps, and bench-test keeps a deliberately loose floor under it because a performance regression here does not announce itself: the picture stays correct and merely arrives late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
2026-09-27 17:28:05 -04:00
src/arthur/domain/ pure. No re-frame, no DOM, no flow/.
src/arthur/demo.cljs the hand-written scene, read from demo/scene.edn
src/arthur/ui/canvas.cljs the one imperative sink — the only DOM canvas call
Port steps 0-1: scaffold, the oracle, and the pure bottom Scaffolds frontend/ (shadow-cljs, reagent 1.2.0, re-frame 1.4.3) and ports everything below the data model, with the JS kept as a numeric oracle. domain/landmarks index tables, verbatim domain/ring subsample, offset, simplicity domain/geom similarity fit, procrustes, moving average domain/raster indexed scanline fill, stencil, disc, rect domain/palette the ramp, and the no-sampled-RGB rule 58 tests, 166 assertions. Parity with js/ on the identical 72-frame synthetic track: fit-similarity, procrustes-mean, fit-residual, moving-average, smooth-transforms, offset-ring and subsample-slots to 1e-9; the raster pixel-for-pixel over the whole buffer. Three deviations from the JS, each for a reason: - synth.cljs jitters from a SEEDED generator, not Math.random. Parity is only checkable if both sides can be handed the same track, and a failing assertion has to be reproducible. `:rand-fn` takes the generator over, so oracle.mjs stubs js/Math.random and js/ itself stays untouched. - raster/->rgba replaces toImageData. ImageData is a DOM type and domain/ may not touch the DOM; returning plain bytes also lets the no-intermediate-colours assertion run in node. ui/canvas wraps it later. - offset-ring lives in domain/ring, not domain/geom, per architecture.md: it is an operation on an ordered traversal, not on a transform. Step 1's "done" also names the swapped-iris vote, but pairIrises is in pipeline.js and belongs to step 7. The precondition is asserted instead -- `:swap-iris` really does move both blocks -- so the vote will have a track that disagrees with it when it arrives. One finding, recorded in full in the test that measures it: smooth-transforms buys nothing on the synthetic track. Against jitter-free ground truth, radius 1 helps by 17% on one noise realisation and hurts by 0.5% on another, so its benefit is within noise; from radius 2 up the cost is unambiguous, and by radius 5 the filter is below the true motion's own high-frequency energy, i.e. smoothing away performance. The test pins the shape of the knob rather than a preferred value. This may say more about the synth's jitter being unrealistically small (+/-0.001 normalised) than about the knob; step 6 settles it on real footage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 14:43:34 -04:00
test/arthur/synth.cljs the synthetic track — test infrastructure, not src
test/parity/ the JS oracle harness. Deletable at step 5.
public/index.html dev host page. Django replaces it at step 9.
```
Port steps 2-3: the data model and the player Steps 2 and 3 land together because the model revisions in the middle changed code from both, and splitting them now would invent intermediate states that never built. domain/channel value-at across framed/keyed/dense, plus a cursor domain/node decomposed transform, composition order, time maps domain/scene topological order, z paths, eval-frame and resolver clock audio-clocked frame derivation, outside app-db db/events/subs re-frame arrives; the playhead is document state ui/player the rAF loop; reads, blits, dispatches (almost) nothing ui/shell transport 133 tests, 1158 assertions. The scene plays at 30fps against audio, scrubs, and runs at 1/4x through 4x; verified by driving a real browser over CDP rather than by assertion. Two evaluators, on purpose. `eval-frame` is the specification -- allocating, order-free, obviously correct. `resolver` is what playback uses: cached topo order and z paths, a cursor per channel, a preallocated point buffer per node. Both run the same walk, parameterised only by how a channel is read and where points are written, because two independent implementations of frame evaluation would drift and the drift would read as a rendering bug rather than as two functions disagreeing. scene-test asserts they agree frame for frame in forward, backward and random order. Deviations and decisions, each with a reason: - raster/fill-poly! is now a thin wrapper over fill-poly-buf!, which takes a flat preallocated buffer. ONE scanline fill serves the analysis stages, which speak {:x :y}, and frame evaluation, which hands over a buffer it owns. The parity suite still passes pixel-for-pixel, which is what makes the rewrite safe. - The state mask carries ABSENCE ONLY. An earlier draft gave it a hidden bit too, per architecture.md's "hidden flag + palette index", and that bit was a dense [:vis] wearing a different hat -- two mechanisms for one question, which is how a part ends up hidden by one and shown by the other. - The palette is a parameter of evaluation, not a global. A node names a TONE; which ramp that tone is read in belongs to the timeline it sits in. - :over layers and a symbol :rate THROW rather than being ignored. Neither is built and nothing can produce one, so this can only fire on data that has run ahead of the code. A silently dropped override is a hand correction the user made once, watched fail, and has no reason to trust again. Three findings the model produced rather than received: - Presence propagates asymmetrically. An absent transform drops the subtree; an absent [:geom :pts] drops only that node, because an absent mouth outline has nothing to draw but the head it hangs off has not moved. That asymmetry is the reason presence is tracked per channel and not per node. - Z paths need lexicographic compare, not `compare`, which orders vectors by count first -- so a cel three levels under "a1" would jump in front of a bare "a2" and the layer order would mostly work. - A node stencilled by something that drew nothing is dropped, not drawn unclipped: an iris floating over the cheek is worse than a missing iris. docs/ revised alongside, and those revisions are the load-bearing part: - A scene, a timeline and a symbol are one type. The doc had two structures with the same fields and never said so. Two axes of nesting are now separated -- parent/child within a timeline is flat with parent pointers, instance nesting is by reference -- which is why "nestable" and "flat" only sounded contradictory. - Palettes are named, live on the project, and are ENABLED on a timeline as a channel. Absent inherits; present travels with the timeline, so a symbol authored against :night stays night wherever it is placed. The output index space is the concatenation of the named ramps, which keeps one buffer and one flat table and incidentally stops two nodes in different palettes colliding on a stencil. - Stabilisation is a channel, not a mode: {s, theta, tx, ty} IS [:xform :*], so the normalise on/off/per-plate toggle is which of the three channel shapes the :head node carries. Always measure and always store factored -- smoothing and velocity-minimum key selection both need the split to exist in storage. - There is no camera node and none is needed. Placement is a node transform, the stage clips what hangs off it, and project dimensions are independent of the footage. `makeXform` is therefore not to be ported: it bakes a cropping decision into every stored vertex. - Export is removed. The .take writer was for an Animator Pro render script; the target is encoding video in the browser, and step 9 now says not to port the old one. demo/swarm is 120 shapes on six orbits, entirely dense blocks behind store handles -- the shape freeze produces at step 5, and the first thing to exercise that path under load. It plays at 30fps, and bench-test keeps a deliberately loose floor under it because a performance regression here does not announce itself: the picture stays correct and merely arrives late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PDfHGdV39zu6rvgbBTfDaT
2026-09-27 17:28:05 -04:00
## Two evaluators, on purpose
`domain/scene` has both `eval-frame` and `resolver`, and they are not
alternatives:
- **`(eval-frame scene f store)`** is the specification. Allocating, order-free,
obviously correct. Tests and one-off renders use it.
- **`(resolver scene store)` -> `(fn [f] ops)`** is what playback uses. It caches
the topological order and the z paths, holds a cursor per channel and reuses
one point buffer per node, so a frame allocates the op maps and nothing else.
Both run the same walk, parameterised by how a channel is read and where its
points are written — two independent implementations of frame evaluation would
drift, and the drift would look like a rendering bug rather than like two
functions disagreeing. What differs between them is exactly the part that can be
wrong, and `scene-test` asserts they agree frame for frame in forward, backward
and random order.
Because the resolver reuses its buffers, **ops must be rasterised before the
next frame is asked for.** That is the contract the rAF loop wants anyway: it
reads, blits, and dispatches nothing.