arthur/frontend/README.md

335 lines
17 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
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
mise install # from the REPO ROOT
pip install -r requirements.txt # the Django half; one dependency
mise exec -- python manage.py migrate # the document database
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
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 && mise exec -- npm test
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
```
Two things: compile the `:test` build, run it under node.
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
```
shadow-cljs compile test
node out/node-tests.js
```
Run them separately if a compile error is in the way.
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
**Run them through `mise`**, or make sure `mise`'s node is first on PATH. `java`
must be 21+ and node 20.19+. On an nvm node 20.11 shadowing the pinned one,
things fail in ways that read like the code being broken and are not.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
### And the Django one
```sh
mise exec -- python manage.py test clips # from the REPO ROOT
```
Tests cover the API: the blob store, key verification, the load/save round trip,
the conditional write, source analysis blocks, and video upload and extraction.
The two groups worth
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
reading are the ones that make the tier split a property of the system rather than
a convention in ClojureScript — the server recomputes every tier-2 key it is
handed, and refuses a block whose analysis does not declare a detector version.
### And the browser one
Step 5's done-criterion is a PICTURE, and no assertion in `cljs.test` can check
one: a take that resolves to the right numbers and draws nothing would pass every
test in `arthur.flow.freeze-test`. A blank canvas under a perfectly correct
transport is the bug class unit tests miss, and it has happened here once.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
Step 9's is a picture too, for a different reason: the ways a document survives a
round trip LOOKING correct are the interesting ones. So the suite now also saves
the take, reopens it, and checks the frames are the same pixels.
It drives a real Chrome over CDP, and needs both processes up:
```sh
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
mise exec -- python manage.py runserver 8778 # from the REPO ROOT
cd frontend && mise exec -- npx shadow-cljs watch app # in one shell
cd frontend && mise exec -- npm run browser # in another
```
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
`ARTHUR_URL` overrides the page it drives; it defaults to
`http://localhost:8778/index.html`, which since step 9 is Django's.
No dependencies. Playwright is not installed and CDP needs none —
`node --experimental-websocket` has a global `WebSocket` and
`--headless=new --remote-debugging-port=N` is the whole of the other side. It
reads the canvas's own pixels rather than a screenshot, because the CSS scales
the stage up by 2 and a screenshot is four pixels per raster pixel; it writes
PNGs into `test/browser/out/` anyway, so "it drew something" can be checked by
eye as well as by count.
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
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
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
Two processes, which do not talk to each other:
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
```sh
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
mise exec -- python manage.py runserver 8778 # from the REPO ROOT
cd frontend && mise exec -- npx shadow-cljs watch app
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
```
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
Then open **<http://localhost:8778/>**. Django serves the page from
`clips/templates/clips/index.html`, and staticfiles serves the bundle out of
`static/arthur/js`, where `shadow-cljs` already writes it — so nothing copies files
between the two.
### Paint sketch
Click **new polygon**, place at least three vertices on the stage, then click
**finish shape**. Select a shape to drag its vertices. Scrub to another frame and
click **new drawing key** to copy the visible outline there; the previous drawing
holds until that key. The numbered drawing-key buttons jump to editable keys.
The transition control between two drawing keys can switch that gap between a
hold and linear vertex tweening. Other gaps keep their own timing. Tweening works
best when the same vertex
keeps the same meaning in every drawing. Paint shapes use the timeline clock
directly, so the roto exposure grid does not delay a drawing key or step its
tween. Use the project **save** button to persist the drawings.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
`/index.html` still works, and that is deliberate: it is the URL the browser suite
has used since step 5, when shadow-cljs's `:dev-http` did no directory-index
resolution and the suite learned to ask for the file.
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
Four built-in clips, on buttons in the transport:
| | |
| --- | --- |
| `take` | the synthetic take, head **as filmed**. Step 5's deliverable: a moving mouth, frozen into dense channels, with no video file anywhere. |
| `locked` | the same freeze, head **locked**. The same blocks — `:head`'s channels are written as framed identity instead of as a dense track, and nothing in tier 2 differs. |
| `demo` | the hand-written scene from step 2. Not a face: the smallest scene that exercises every mechanism the model claims to have, so that each one is visible when it breaks. |
| `swarm` | a hundred and twenty dense nodes. Not useful; it is the load test. |
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
`take` and `locked` are the pair worth looking at together, because switching
between them is the whole of what "stabilisation is a channel, not a mode" means.
The demo scene itself is `src/arthur/demo/scene.edn`. Both the synthetic take
and real footage use `src/arthur/flow/take.cljs` for the measurement order and
`src/arthur/flow/freeze.cljs` for the landmark-to-channel conversion.
**stage 8625** loads the locally saved `IMG_8625.MOV` project and places its
post-processed timeline twice. The stage layout is
`src/arthur/demo/stage_8625.edn`: the right picture and sound start at frame 48,
and the two pictures overlap slightly in stage space. Audio has its own timeline
nodes, linked to the picture instances but with independent spans and gain
channels. The right sound swells and pans across the stage, then fades out at
frame 260 while its picture continues to
frame 280. The button needs that saved 8625 project in the local server database.
### Projects and the EDN fixtures
The EDN files under `demo/` are authored examples compiled into the frontend.
They seed a clip in memory; the server does not read EDN. Clicking **save** on a
clip without a project id creates a project through `POST /api/projects`, uploads
any missing content-addressed blocks, then writes the clip's addressed leaves
through `PUT /api/projects/<id>`. Each leaf value is Transit JSON inside the
request's ordinary JSON envelope. Python stores those values in JSON columns and
does not need an EDN parser. **open** reads the leaves and blocks and rebuilds
the same ClojureScript clip.
The intended editor creates and changes that in-memory clip directly: a project
browser and **new stage** action, timeline instance placement, node and channel
editors, then the existing save path. EDN remains useful for checked-in examples
and reproducible studies. The current UI has save and open, but no project
browser, blank-stage action, or authoring controls yet; open chooses the most
recent project.
### Real footage
Measure the video, not a PNG per frame Detection now walks a browser-seekable H.264 proxy in MediaPipe's VIDEO running mode. The PNG sequence it replaces was 112MB for 7.6 seconds at 1440x1920 and 1.1GB at the 900-frame limit; the proxy is 6MB, and landmarks detected off decoded H.264 rather than off the PNGs moved at most 0.0033 of frame width. Three things had to be true for video mode to work, and each was measured against the same footage decoded to PNGs: /blob/<digest> answers byte ranges. Django's FileResponse does no Range handling, and a media element handed 200 with no Accept-Ranges reports an empty `seekable`, no-ops every currentTime write, and detects frame one ninety times without raising. A seek aims at the MIDDLE of its frame. Aiming at i/fps sits on a frame boundary and landed one frame early 31 times in 91; (i + 0.5)/fps was exact on all 91. Timestamps are strictly increasing footage milliseconds. Video mode is a tracker: a repeat leaves the graph in an error state every later call re-throws, so the landmarker is discarded on failure, and passing the frame index instead of i*1000/fps moved landmarks six times further from the per-frame answer. Frames are verified rather than trusted. requestVideoFrameCallback states which frame it handed over, the walker discards any other and fails loudly if the one it asked for never arrives — a stale presentation from the tail of a previous seek is what produced "asked for frame 1 and it presented frame 2" on a video whose seeks were in fact exact. The proxy is re-encoded even when the upload is already H.264: HEVC is not decodable everywhere, and footage identity is the proxy's digest. The JPEG stills beside it are tracing references, outside the footage digest because re-rendering them at another size is not different footage. Verified end to end in a real browser against real footage: 228/228 frames detected, a drawn roto face, 37 backend and 234 frontend tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 11:32:01 -04:00
Choose a video in the **footage** file input. The server probes it, re-encodes it
to an H.264 proxy and a raw stream of the same coded frames, pulls WAV audio and one tracing JPEG per frame,
Measure the video, not a PNG per frame Detection now walks a browser-seekable H.264 proxy in MediaPipe's VIDEO running mode. The PNG sequence it replaces was 112MB for 7.6 seconds at 1440x1920 and 1.1GB at the 900-frame limit; the proxy is 6MB, and landmarks detected off decoded H.264 rather than off the PNGs moved at most 0.0033 of frame width. Three things had to be true for video mode to work, and each was measured against the same footage decoded to PNGs: /blob/<digest> answers byte ranges. Django's FileResponse does no Range handling, and a media element handed 200 with no Accept-Ranges reports an empty `seekable`, no-ops every currentTime write, and detects frame one ninety times without raising. A seek aims at the MIDDLE of its frame. Aiming at i/fps sits on a frame boundary and landed one frame early 31 times in 91; (i + 0.5)/fps was exact on all 91. Timestamps are strictly increasing footage milliseconds. Video mode is a tracker: a repeat leaves the graph in an error state every later call re-throws, so the landmarker is discarded on failure, and passing the frame index instead of i*1000/fps moved landmarks six times further from the per-frame answer. Frames are verified rather than trusted. requestVideoFrameCallback states which frame it handed over, the walker discards any other and fails loudly if the one it asked for never arrives — a stale presentation from the tail of a previous seek is what produced "asked for frame 1 and it presented frame 2" on a video whose seeks were in fact exact. The proxy is re-encoded even when the upload is already H.264: HEVC is not decodable everywhere, and footage identity is the proxy's digest. The JPEG stills beside it are tracing references, outside the footage digest because re-rendering them at another size is not different footage. Verified end to end in a real browser against real footage: 228/228 frames detected, a drawn roto face, 37 backend and 234 frontend tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 11:32:01 -04:00
then makes the resulting footage selectable. Click **load frames** to detect and
freeze it. Extraction progress is currently read from `/api/extractions/<key>`; a
future WebSocket can push the same job state. The uploaded bytes, extraction job,
and decoded footage have separate records, so the same uploaded video can be
reopened without decoding it again.
**The proxy is what gets measured, and the stills are not.** `flow/ingest` cuts
its raw H.264 stream into coded frames and decodes them in order with WebCodecs.
The proxy has no B-frames, so decode order matches frame order. `flow/detect`
hands each decoded frame to MediaPipe in **VIDEO** running mode at
`i * 1000 / fps` milliseconds. That timestamp has to increase
Measure the video, not a PNG per frame Detection now walks a browser-seekable H.264 proxy in MediaPipe's VIDEO running mode. The PNG sequence it replaces was 112MB for 7.6 seconds at 1440x1920 and 1.1GB at the 900-frame limit; the proxy is 6MB, and landmarks detected off decoded H.264 rather than off the PNGs moved at most 0.0033 of frame width. Three things had to be true for video mode to work, and each was measured against the same footage decoded to PNGs: /blob/<digest> answers byte ranges. Django's FileResponse does no Range handling, and a media element handed 200 with no Accept-Ranges reports an empty `seekable`, no-ops every currentTime write, and detects frame one ninety times without raising. A seek aims at the MIDDLE of its frame. Aiming at i/fps sits on a frame boundary and landed one frame early 31 times in 91; (i + 0.5)/fps was exact on all 91. Timestamps are strictly increasing footage milliseconds. Video mode is a tracker: a repeat leaves the graph in an error state every later call re-throws, so the landmarker is discarded on failure, and passing the frame index instead of i*1000/fps moved landmarks six times further from the per-frame answer. Frames are verified rather than trusted. requestVideoFrameCallback states which frame it handed over, the walker discards any other and fails loudly if the one it asked for never arrives — a stale presentation from the tail of a previous seek is what produced "asked for frame 1 and it presented frame 2" on a video whose seeks were in fact exact. The proxy is re-encoded even when the upload is already H.264: HEVC is not decodable everywhere, and footage identity is the proxy's digest. The JPEG stills beside it are tracing references, outside the footage digest because re-rendering them at another size is not different footage. Verified end to end in a real browser against real footage: 228/228 frames detected, a drawn roto face, 37 backend and 234 frontend tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 11:32:01 -04:00
strictly and has to be real footage time: video mode is a tracker, it reads the
gap between timestamps as motion, and a repeat leaves the graph in an error state
that every later call re-throws. The JPEGs beside the proxy are reference images
for the tracing editor and nothing measures them, so they are not in the footage
digest.
It is re-encoded even when the upload is already H.264, for two reasons: an
iPhone's HEVC is not decodable in every browser, and the footage's identity is the
proxy's digest — one produced by one ffmpeg invocation, not one that depends on
which branch the source happened to take. Its raw stream is copied from that
proxy without another encode.
The command-line route is also available for an existing extracted bundle:
```sh
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
./extract.sh /path/to/clip.mov # decode to frames + audio + manifest
mise exec -- python manage.py ingest_bundle
```
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
`extract.sh` keeps every source frame and writes `frames/0001.png` onward,
`audio.wav` and `manifest.json`. Variable frame rate sources are rejected until the
manifest and clock carry per-frame timestamps.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
`ingest_bundle` then hashes all of it into the content-addressed blob store under
`var/blobs` — by hard link, so 112MB of PNGs is not copied — and registers one
`Footage` row. From then on the frames are the backend's: `GET /api/footage/<id>`
answers with a manifest carrying **a URL per frame**, and the app fetches those.
That replaced a shared secret. Until step 9 the page fetched `/manifest.json` off
the filesystem and built `frames/0001.png` itself, with shadow-cljs serving the
repo root — so the frame layout was agreed between a shell script and a
ClojureScript namespace, and "where are the frames" was answered by a directory
listing. The cache-busting `?v=` that used to hang off every frame URL went with
it: a blob's name is the hash of its bytes, so re-extracting gives a frame a
different URL rather than overwriting one.
To keep several takes, pass a bundle directory; each ingests separately and both
stay selectable in the app.
```sh
./extract.sh /path/to/clip.mov scratch/my-take
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
mise exec -- python manage.py ingest_bundle scratch/my-take
```
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
`scratch/` is ignored by Git, as are `frames/`, `audio.wav` and `manifest.json` at
the root — all of it is extraction output, and tier 3 does not belong in the repo.
Loading detects one face per frame, measures the mouth, eyes and brows from
landmarks and the teeth from source pixels, then freezes them into channels,
and adds a button for the footage clip. Detection happens once when you load;
playback only resolves channels and paints. Frames without a detection remain
marked absent even though their neighbouring poses are used to condition the
Give features stable identity, eye pairs and per-feature presence Step 8's data model, ahead of its controls. Nothing here is a UI. domain/params holds every knob's definition once — default, applicable area, value constraints and the areas a change would force to regenerate. flow/take's literal knob map becomes a view of it, so the take's defaults and the future parameter panel cannot drift apart. domain/feature adds subjects, features and groups as document data the renderer never reads. A feature ID is stable for the whole clip, across occlusion: a run of visible frames is not a new identity. An eye pair is an explicit group of one or two eyes of the same subject, so a profile view with one identified eye needs no invented partner. Settings resolve area -> subject -> group -> feature, and dropping an eye from a pair materialises its effective values first so playback does not jump. scene/problems now validates all of it. Presence becomes per-feature rather than per-subject. freeze's :absent predicate takes a track as well as a frame, so one occluded eye can be absent while its partner still has a value; a full-face miss still marks everything absent. A manifest may annotate known gaps as one-based inclusive intervals, which ingest expands into observation tracks before measurement. An unobserved eye then gets no vote in the iris pairing and cannot steer the shared gaze — gaze falls back to whichever eye is visible. Temporal filters still see a sample on every frame, held from the last observed one, because the numbers are a rectangular buffer; the state mask, not the buffer, is what says the frame has no value. js/app.js gets the same occlusion lesson: leading nulls from a face that starts occluded used to throw away the whole take, and the neutral frame could be chosen from a held duplicate pose. Parameter editing, scoped regeneration and a feature-level detector remain. Until one exists, footage without annotations falls back to the full-face mask rather than claiming occlusions it cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
2026-09-27 22:44:36 -04:00
track. The scene now records stable subject and feature IDs and explicit eye
pairs; dense channels can mark one feature absent while another is observed.
Current MediaPipe loading supplies only the full-face detection mask. The stage
stays 320×200 regardless of the footage dimensions. Real
footage starts at the source picture rate. The **picture fps** buttons sample the
frozen roto at lower rates while the source track, duration and audio clock stay
unchanged. Picking frames to trace into cels is a separate future editing step.
**save** also stores the detection mask, dense landmarks and raw RGBA mouth crops
as three analysis blocks. **open** restores these without running MediaPipe or
loading source PNGs. The frozen shapes remain separate channel blocks.
Give features stable identity, eye pairs and per-feature presence Step 8's data model, ahead of its controls. Nothing here is a UI. domain/params holds every knob's definition once — default, applicable area, value constraints and the areas a change would force to regenerate. flow/take's literal knob map becomes a view of it, so the take's defaults and the future parameter panel cannot drift apart. domain/feature adds subjects, features and groups as document data the renderer never reads. A feature ID is stable for the whole clip, across occlusion: a run of visible frames is not a new identity. An eye pair is an explicit group of one or two eyes of the same subject, so a profile view with one identified eye needs no invented partner. Settings resolve area -> subject -> group -> feature, and dropping an eye from a pair materialises its effective values first so playback does not jump. scene/problems now validates all of it. Presence becomes per-feature rather than per-subject. freeze's :absent predicate takes a track as well as a frame, so one occluded eye can be absent while its partner still has a value; a full-face miss still marks everything absent. A manifest may annotate known gaps as one-based inclusive intervals, which ingest expands into observation tracks before measurement. An unobserved eye then gets no vote in the iris pairing and cannot steer the shared gaze — gaze falls back to whichever eye is visible. Temporal filters still see a sample on every frame, held from the last observed one, because the numbers are a rectangular buffer; the state mask, not the buffer, is what says the frame has no value. js/app.js gets the same occlusion lesson: leading nulls from a face that starts occluded used to throw away the whole take, and the neutral frame could be chosen from a held duplicate pose. Parameter editing, scoped regeneration and a feature-level detector remain. Until one exists, footage without annotations falls back to the full-face mask rather than claiming occlusions it cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B87NVmiU36qQmN9gmFYnJ9
2026-09-27 22:44:36 -04:00
For known occlusion intervals, an extracted manifest may add
`"feature-absence": {"eye-r": [[10, 14]]}`. Frame numbers are one-based and
inclusive, matching PNG filenames. The eye remains the same feature when it
reappears; the other eye and the mouth continue through the gap. This is an
input annotation, with no UI for editing it yet.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
MediaPipe's JS, wasm and model are under `public/mediapipe/`, served by Django's
staticfiles under `/static/mediapipe/`. No CDN is used by this app. See that
directory's README for provenance.
The server reports what it serves at `GET /api/detector`: the package version plus
the **sha256 of the model asset**, and that string goes inside the content address
of every block a detection produces. Asked rather than assumed, because a version
constant in the client is one somebody has to remember to bump — and
`docs/architecture.md` is explicit that a model upgrade silently reusing old
landmarks presents as "the tool got worse", with no event to attach it to.
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.
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
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
## Saving
**save** and **open** in the transport. A save has three ordered stages:
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
is the tier split:
1. the **analysis** record, so every block stored afterwards can name the detector
version that produced it. The server refuses a block whose analysis it does not
know.
2. ask which **blocks** are missing, upload the source analysis blocks and frozen
channel blocks, then link the source blocks to the analysis.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
3. the **document** — tier 1, as leaves. The server refuses a clip that names
blocks it does not hold, so a saved document cannot load into a blank stage
somewhere else.
The status line says what happened: `saved r3 · 64 leaves · 8 blocks`. Saving an
unchanged document says `0 leaves · 0 blocks`, which is both halves of the
addressing working at once — an unchanged leaf keeps its version, and a
content-addressed block is already there.
Two things are deliberately visible as failures. Saving `swarm` is refused,
because its blocks have hand-written names and a document may only name content
addresses. And **open** takes the most recently updated project and shows its first
clip: there is no project browser, and the store holds one clip at a time.
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 oracle, which is finished
Port step 4: measure the anchor and the mouth, condition on its own `stabilize` is three things wearing one name, and it is now three functions in two stages: `flow/measure/anchor` fits the rigid transform, `flow/condition` smooths its parameters, `flow/measure/mouth` measures the lip rings through the result. Parity is on the COMPOSITION and not on the pieces -- a split that agreed function by function and not end to end would be a split rather than a port. The oracle now drives `stabilize` at three configurations and the port agrees to 1e-9 on ref, rigid, transforms, outer, inner and aperture, plus `smoothContours` at three radii. Two of the three configurations are at aspect 0.5625, a 1080x1920 phone clip, because at aspect 1 `pick` is the identity: a port that dropped the anisotropy correction outright would pass every other assertion in the suite. 148 tests, up from 134. Three decisions worth the reading time. `makeXform` is not ported, and its absence takes the face oval with it. It centres on the oval's bounding box and zooms until the face is 80% of the raster height, so every vertex it touched carried a cropping decision made once, at analysis time, from one frame's landmarks. Geometry belongs in the node's own local space with the framing as a transform on a node, so this is a deletion. The oval's only other consumer was the placeholder plate outline, which is painting. The residual is taken against the RAW fit, and the prototype took it against the smoothed one. That is the only deliberate numeric divergence here, and parity is kept by asserting `anchor/residuals` on exactly what the prototype handed it. The number's job is to say whether a section is stabilisable at all; folding the smoothing error into it makes a slider look like a property of the footage, and docs/architecture.md lists the residual under stage 3, which requires it to be knob-free. `condition/anchor` therefore replaces `:transforms` and leaves `:residual` alone. The stage order is not the strict chain the table in docs/architecture.md looks like, and that document now says so. The fit is knob-free, conditioning smooths it, and the rings are measured *through* the conditioned transform -- so `anchor avg` does re-run the ring mapping, which is a few hundred frames of twenty points. The guarantee was only ever about the part that reads a source pixel, and that part never sees a transform. Two things fall out and are asserted rather than assumed. Smoothing and subsampling commute, because both are per-slot, which is what lets `vertices` stay a stage-5 knob downstream of a stage-4 one -- and it is also why the port can smooth the full twenty slots where the prototype smooths eight and still match. And `condition/contours` is `geom/moving-average` per vertex per axis rather than its own clamped window, so "radius 2" cannot come to mean two different things at the two knobs. One dead end recorded so nobody walks it twice: the synth's head is perfectly rigid -- its jitter is a whole-head translation, which a similarity absorbs exactly -- so every frame's rigid configuration is congruent with frame zero's and the Procrustes mean IS frame zero to 1e-15, jitter or none. "The reference is the mean and not frame zero" cannot be asserted on this track and is asserted in geom-test, where the two can differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:00:11 -04:00
`js/` was the numeric oracle through step 4: `test/parity/` ran both
implementations on the same synthetic track and diffed `fit-similarity`,
`procrustes-mean`, the raster and `stabilize` to 1e-9.
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
**It was deleted at step 5, on purpose.** Parity proves the port is FAITHFUL, not
that the answer is RIGHT. The JS is a prototype and several of its conclusions
contradict each other; a parity test pins behaviour while code moves, and keeping
it afterwards would bake the prototype's mistakes into the rewrite and make them
permanent. `docs/port-plan.md` says to delete it in one commit once the CLJS
player renders the synthetic take, and that is what happened.
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
`js/` itself stays as the reference for the MediaPipe setup, face measurements
and pixel extraction. Its comments encode bugs that actually happened.
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
## 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/.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
src/arthur/fx/ the only namespaces that talk to the network
Port step 4: measure the anchor and the mouth, condition on its own `stabilize` is three things wearing one name, and it is now three functions in two stages: `flow/measure/anchor` fits the rigid transform, `flow/condition` smooths its parameters, `flow/measure/mouth` measures the lip rings through the result. Parity is on the COMPOSITION and not on the pieces -- a split that agreed function by function and not end to end would be a split rather than a port. The oracle now drives `stabilize` at three configurations and the port agrees to 1e-9 on ref, rigid, transforms, outer, inner and aperture, plus `smoothContours` at three radii. Two of the three configurations are at aspect 0.5625, a 1080x1920 phone clip, because at aspect 1 `pick` is the identity: a port that dropped the anisotropy correction outright would pass every other assertion in the suite. 148 tests, up from 134. Three decisions worth the reading time. `makeXform` is not ported, and its absence takes the face oval with it. It centres on the oval's bounding box and zooms until the face is 80% of the raster height, so every vertex it touched carried a cropping decision made once, at analysis time, from one frame's landmarks. Geometry belongs in the node's own local space with the framing as a transform on a node, so this is a deletion. The oval's only other consumer was the placeholder plate outline, which is painting. The residual is taken against the RAW fit, and the prototype took it against the smoothed one. That is the only deliberate numeric divergence here, and parity is kept by asserting `anchor/residuals` on exactly what the prototype handed it. The number's job is to say whether a section is stabilisable at all; folding the smoothing error into it makes a slider look like a property of the footage, and docs/architecture.md lists the residual under stage 3, which requires it to be knob-free. `condition/anchor` therefore replaces `:transforms` and leaves `:residual` alone. The stage order is not the strict chain the table in docs/architecture.md looks like, and that document now says so. The fit is knob-free, conditioning smooths it, and the rings are measured *through* the conditioned transform -- so `anchor avg` does re-run the ring mapping, which is a few hundred frames of twenty points. The guarantee was only ever about the part that reads a source pixel, and that part never sees a transform. Two things fall out and are asserted rather than assumed. Smoothing and subsampling commute, because both are per-slot, which is what lets `vertices` stay a stage-5 knob downstream of a stage-4 one -- and it is also why the port can smooth the full twenty slots where the prototype smooths eight and still match. And `condition/contours` is `geom/moving-average` per vertex per axis rather than its own clamped window, so "radius 2" cannot come to mean two different things at the two knobs. One dead end recorded so nobody walks it twice: the synth's head is perfectly rigid -- its jitter is a whole-head translation, which a similarity absorbs exactly -- so every frame's rigid configuration is congruent with frame zero's and the Procrustes mean IS frame zero to 1e-15, jitter or none. "The reference is the mean and not frame zero" cannot be asserted on this track and is asserted in geom-test, where the two can differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:00:11 -04:00
src/arthur/flow/ the stages. `(f params inputs) -> output`, no state.
src/arthur/synth.cljs the synthetic track. In src/ because the take PLAYS it —
it stands in for flow/detect, and a tool that needs a
video file before it shows you anything is one you
cannot debug.
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/demo.cljs the hand-written scene, read from demo/scene.edn
src/arthur/demo/take.cljs the synthetic source for the shared flow/take path
src/arthur/ui/canvas.cljs indexed raster blit to the display canvas
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
test/arthur/support/ machinery shared between suites; not tests itself
test/browser/ drives a real Chrome over CDP. Not run by `npm test`.
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
public/mediapipe/ vendored wasm and model, served under /static/mediapipe/
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 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
Serve the document from a Django backend, split into three tiers Step 9. The tier split was the work; Django was the easy half. Tier 1 — the authored scene — is the document, and it is addressed as independently versioned leaves rather than saved whole, so one vertex drag cannot clobber a collaborator's keying. `domain/leaf` is the document as path -> value; `domain/wire` puts it on the wire as transit, because JSON has neither integer map keys nor keywords and a save would quietly turn `{0 v}` into `{"0" v}`. Tier 2 — the dense channel blocks — is content-addressed by a hash over every input, with the detector version inside every key through the analysis the block descriptor names. `flow/address`'s `block-knobs` is the invalidation table, and `address-test` does not trust it: it re-freezes the take once per knob and asserts the biconditional, that a block's bytes changed if and only if its key changed. That found `brow-pos` not depending on `contour-avg` — the brow ring is smoothed, the raise is not. Tier 3 — frames and audio — is served by the hash of its bytes out of the same store. A manifest now names frames and carries a URL for each, so the frame layout stopped being a shared secret between a shell script and a ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's name is the hash of its contents, so a stale copy is not a thing that can happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an asset the project owns, not an extraction that churns. The server verifies rather than trusting a name it was handed: it recomputes every key from the descriptor stored beside it, refuses an analysis that declares no detector version, and refuses a document naming blocks it does not hold. It hashes the descriptor TEXT, because JS prints an integral double as `1` and Python as `1.0`, and a scheme where both ends re-render the numbers disagrees on the first parameter that happens to be whole. Two loose ends from step 8 closed on the way. `pack` no longer takes a `(track, frame)` predicate whose call sites each re-derived a feature from an index — every track names the feature it follows, which deleted five hand-maintained mappings. And `:dev-http` is gone: Django serves the page, shadow-cljs only builds into the staticfiles tree. 227 CLJS tests, 31 Django tests, green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 01:11:41 -04:00
`public/` holds nothing but those assets now. The host page that used to sit beside
them is `clips/templates/clips/index.html`.
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
2026-09-28 02:33:26 -04:00
`domain/timeline` has both `eval-frame` and `resolver`, and they are not
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
alternatives:
2026-09-28 02:33:26 -04:00
- **`(eval-frame timeline f store)`** is the specification. Allocating, order-free,
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
obviously correct. Tests and one-off renders use it.
2026-09-28 02:33:26 -04:00
- **`(resolver timeline store)` -> `(fn [f] ops)`** is what playback uses. It caches
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 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.