arthur/frontend
2026-09-27 19:01:39 -04:00
..
public Port steps 2-3: the data model and the player 2026-09-27 17:28:05 -04:00
src/arthur Port step 5: freeze measured mouth into playable channels 2026-09-27 19:01:39 -04:00
test Port step 5: freeze measured mouth into playable channels 2026-09-27 19:01:39 -04:00
package-lock.json Port steps 0-1: scaffold, the oracle, and the pure bottom 2026-09-27 14:43:34 -04:00
package.json Port step 5: freeze measured mouth into playable channels 2026-09-27 19:01:39 -04:00
README.md Port step 5: freeze measured mouth into playable channels 2026-09-27 19:01:39 -04:00
shadow-cljs.edn Port steps 0-1: scaffold, the oracle, and the pure bottom 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

mise install          # from the REPO ROOT: java 21+, node 20, clojure, python
cd frontend && npm install

java must be 21+. On an older JDK shadow-cljs fails with "CompilerOptions has been compiled by a more recent version of the Java Runtime", which reads like a shadow-cljs bug and is not one. mise install is what prevents it.

The tests

cd frontend && mise exec -- npm test

Two things: compile the :test build, run it under node.

shadow-cljs compile test
node out/node-tests.js

Run them separately if a compile error is in the way.

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.

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.

So there is a second suite that drives a real Chrome over CDP. It needs the dev server up:

cd frontend && mise exec -- npx shadow-cljs watch app     # in one shell
cd frontend && mise exec -- npm run browser               # in another

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.

The app

cd frontend && mise exec -- npx shadow-cljs watch app

Then open http://localhost:8778/index.html — with the /index.html, not bare /. This shadow-cljs does no directory-index resolution, so / is a 404 whatever the roots are.

Four 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.

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; the take is built in src/arthur/demo/take.cljs, which is also the only place the seven stages are composed in order.

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.

From step 9 Django serves the page and :dev-http goes away.

The oracle, which is finished

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.

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.

js/ itself stays. It is not an oracle any more, it is the SOURCE for steps 6 and 7 — the MediaPipe setup, the eye and brow signals, the interior extraction — and its comments encode bugs that actually happened.

Layout

src/arthur/domain/      pure. No re-frame, no DOM, no flow/.
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.
src/arthur/demo.cljs    the hand-written scene, read from demo/scene.edn
src/arthur/demo/take.cljs  the seven stages composed in order, and the only place
                        they are
src/arthur/ui/canvas.cljs  the one imperative sink — the only DOM canvas call
test/browser/           drives a real Chrome over CDP. Not run by `npm test`.
public/index.html       dev host page. Django replaces it at step 9.

Two evaluators, on purpose

domain/scene has both eval-frame and resolver, and they are not alternatives:

  • (eval-frame scene f store) is the specification. Allocating, order-free, obviously correct. Tests and one-off renders use it.
  • (resolver scene store) -> (fn [f] ops) is what playback uses. It caches the topological order and the z paths, holds a cursor per channel and reuses one point buffer per node, so a frame allocates the op maps and nothing else.

Both run the same walk, parameterised by how a channel is read and where its points are written — two independent implementations of frame evaluation would drift, and the drift would look like a rendering bug rather than like two functions disagreeing. What differs between them is exactly the part that can be wrong, and scene-test asserts they agree frame for frame in forward, backward and random order.

Because the resolver reuses its buffers, ops must be rasterised before the next frame is asked for. That is the contract the rAF loop wants anyway: it reads, blits, and dispatches nothing.