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>
This commit is contained in:
Olive Vaughn 2026-09-27 14:43:34 -04:00
parent 082d8561d2
commit eb06be005c
25 changed files with 4932 additions and 0 deletions

10
.gitignore vendored
View file

@ -3,3 +3,13 @@ frames/
*.take
*.tflite
# CLJS build
frontend/node_modules/
frontend/.shadow-cljs/
frontend/out/
frontend/.cpcache/
static/arthur/js/
# mise-managed venv for the Django half
.venv/

351
docs/animation-model.md Normal file
View file

@ -0,0 +1,351 @@
# arthur — the animation model
The data that describes a moving picture: what the primitives are, how they
nest, how they change over time, and how rotoscoped and hand-authored work end
up being the same thing with one flag between them.
`docs/design.md` is the aesthetic argument. `docs/architecture.md` is where the
code goes. This is the type that both of them are about.
## What this replaces
`docs/design.md` has a table of five kinds of part — plate, feature, interior,
primitive, scalar — each with its own source, vocabulary and interpolation. That
table is a good description of **where data comes from** and a bad description of
**what data is**, and the current code follows it too literally: eyes, brows,
teeth and mouth each get their own build function, their own key shape and their
own path through `take.js`.
They are all one thing. A part is a **node** with **channels**, and the five
kinds collapse into differences of which channels exist and who filled them in.
## Prior art, and what each one gets right
| System | The idea worth taking |
| --- | --- |
| **Flash / SWF** | A **library of symbols** and a timeline of **instances** at depths. "Framed" content that simply exists on a frame, versus tweened content. `DefineMorphShape` requires matching vertex counts — the fixed-topology rule, arrived at from the other direction. |
| **Blender** | Animation is **addressed by path into the data** (`location[0]`), not stored as fields on the object. An Action is a bag of F-Curves. That decoupling is what makes the dope sheet, the graph editor and the NLA three views of one dataset. Also: parenting captures a `parent_inverse` so the child does not jump. |
| **After Effects** | Every leaf property is animatable, uniformly. Property groups form a tree. Pre-comps nest arbitrarily and a pre-comp is just a layer. |
| **Lottie** | The uniform property shape: `{a: 0, k: <value>}` or `{a: 1, k: [<keys>]}`. One representation for static and animated, which is exactly "framed or keyframed". |
| **Grease Pencil** | A 2D layer holds frames at frame numbers, and a frame **holds until the next one**. Hold is the default, not a special case. |
What none of them get right for this project: colour. All four store RGB on the
shape. `docs/design.md` forbids that, so colour is a palette index here and it is
a channel like any other.
## The one idea
**Analysis is a channel generator.** It does not produce a different kind of
data; it produces keys, densely, on the same channels a hand would fill in
sparsely. So:
```
footage ──▶ analysis ──▶ FREEZE ──▶ channels on nodes ──▶ evaluate ──▶ raster
▲
hand authoring ──┘
```
Freezing is not a conversion into a second format. There is one format, and
freezing fills it in. That is what makes "the only difference is a special flag"
literally true: the flag is provenance on a channel, and nothing in the renderer
reads it.
## Node
A node is an instance in the scene. The tree is stored **flat, with parent
pointers** — never as nested maps.
```clojure
{:id :mouth
:name "mouth"
:kind :poly ; :poly :disc :rect :group :bitmap :symbol
:parent :head ; nil at the root
:z "a3" ; fractional index, ordered among all siblings
:symbol nil ; or :sym/blink — see Symbols
:stencil :mouth-in ; colour-key clip; structural, not a channel
:span [0 240] ; in/out in the parent's frame space
:pinv [1 0 0 1 0 0] ; parent-inverse, captured when parented
:channels {...}}
```
Flat with pointers, for four reasons that all point the same way: any node is
addressable without a walk; reparenting is a one-field write rather than a
subtree move; an edit to a leaf does not change the identity of its ancestors, so
re-frame's structural sharing keeps ancestor subs from invalidating; and it is
what lets every node be its own sync leaf. Flash, Blender and AE all store it
this way.
`:span` is Lottie's `ip`/`op` and Flash's `PlaceObject`/`RemoveObject`: the range
over which the node exists at all. Distinct from a `[:vis]` channel, which
blinks an existing node on and off.
## Channel
Every animatable property is a channel, and channels are addressed **by path**:
```clojure
:channels
{[:xform :pos] {:animated? false :value [0.0 0.0]}
[:xform :rot] {:animated? false :value 0.0}
[:xform :scale] {:animated? false :value [1.0 1.0]}
[:xform :skew] {:animated? false :value [0.0 0.0]}
[:xform :anchor]{:animated? false :value [0.0 0.0]}
[:geom :pts] {:animated? true :interp :hold :dense {...} :generated {...}}
[:style :color] {:animated? false :value :skin-dark}
[:vis] {:animated? true :interp :hold :keys {0 true, 37 false}}}
```
A path is a **vector**, not a string — CLJS maps take vectors as keys natively,
so Blender's `data_path` idea arrives with no parsing. The set of valid paths for
a node follows from its `:kind`, and that is a spec, not a schema migration.
Three channel shapes, and the uniformity across them is the point:
```clojure
;; FRAMED — one static thing. No animation, no vertex correspondence to worry
;; about. A painted background cel is this.
{:animated? false :value v}
;; KEYED — sparse, authored, in the document. Undoable and syncable.
{:animated? true :interp :hold :keys {0 v, 4 v, 12 v}}
;; DENSE — generated, one value per frame, held in tier 2 as a typed array.
{:animated? true :interp :hold
:dense {:store "sha256:…" :offset 0 :stride 40 :frames 600}
:generated {...}}
```
`:interp` defaults to `:hold`, which `docs/design.md` requires of every cut part.
A key may carry its own `:interp` to override the channel's, which is how Lottie
and Blender both do per-key easing; nothing uses it yet and the door is cheap to
leave open.
### Keys are a map by frame, not a list
Already argued in `docs/architecture.md` for merge reasons; here it also gives
"the most recent key at or before `f`" as a `rsubseq` on a sorted map instead of
a scan. **Store a plain map** in the document — transit and JSON both lose
sortedness — and build the sorted index in the resolver.
### The flag lives on the channel, not the node
```clojure
:generated {:by :roto/lips-outer
:analysis "sha256:…" ; which analysis artifact
:params {:verts 8 :contour-avg 1 :aperture-cut 0.004}}
```
Present means the UI offers a parameter panel and a re-freeze button. Absent
means the UI offers the keys directly. **The renderer never reads it.**
It belongs on the channel rather than the node because a node routinely wants
both at once: a mouth whose `[:geom :pts]` is rotoscoped and whose `[:xform :pos]`
is hand-animated to sit on a plate. Putting the flag on the node would forbid the
most useful thing in the model.
### Channels are layered
A channel is a base plus optional override layers, and a layer declares how it
combines:
```clojure
{:animated? true :interp :hold
:dense {...} :generated {...}
:over [{:blend :offset :keys {88 [2 0], 96 [0 0]}}
{:blend :replace :keys {104 [[3 7] [4 7] …]}}]}
```
- **`:offset`** adds a delta to the base. "Nudge the mouth two pixels right for
ten frames" survives a re-freeze at different parameters, because it was never
a position — it was a correction.
- **`:replace`** wins outright. For the frame where detection simply failed.
This is what `docs/design.md` means by an override layer, and it is why
re-freezing is safe: the base is regenerated, the layers are untouched. It is
Blender's NLA blending and AE's effect stack at one property.
## Transform: decomposed, never a matrix
```clojure
{:pos [x y] :rot θ :scale [sx sy] :skew [kx ky] :anchor [ax ay]}
```
Stored decomposed for two reasons. Each component has to be independently
keyframable, which is the entire point of channels. And interpolating matrix
entries is meaningless — a rotation tweened through its matrix shears on the way.
Composition, per node:
```
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
world = world(parent) · pinv · local
```
`:anchor` is Flash's registration point and Blender's origin: rotation and scale
happen about it, and getting it wrong is why hand-placed parts swing rather than
turn.
`:pinv` is Blender's `parent_inverse`, captured at the moment of parenting so the
child does not jump when it acquires a parent. Small, and its absence is the kind
of thing that makes a parenting feature feel broken.
**The similarity fit already produces a decomposition.** `fitSimilarity` returns
`{s θ tx ty}`, which drops straight into `[:xform :scale]`, `[:xform :rot]` and
`[:xform :pos]` with no conversion. The analysis output and the animation model
meet without an adapter, which is a sign the decomposition is the right one.
## Time maps — exposure, lead and symbol timing are one thing
Every node may map the frame it is evaluated at:
```clojure
:time {:mode :inherit} ; the default, and almost always right
:time {:mode :map :expose 2 :offset -1 :rate 1.0 :loop? false}
```
Three features that look unrelated are this one mechanism:
- **exposure** is `⌊f/n⌋·n`,
- **mouth lead** is `f + k`,
- **a symbol instance's timing** is `(f - at)·rate + in`, with optional looping.
Composed along the nesting chain, outermost first. Two rules follow, and they are
different rules:
- **Exposure inherits strictly.** `docs/design.md` is emphatic that everything
rides one grid, because a head cutting on odd frames against a mouth cutting on
even ones reads as two performances. The model permits a per-node grid; the
default must be `:inherit`, and setting it lower is a deliberate act the UI
should make feel like one.
- **Offset is per-node by design.** Mouth lead applies to performance nodes and
*not* to the plate, which is the whole point of it — so the offset genuinely
belongs at the node, not the clip.
## Symbols
The Flash idea, kept:
```clojure
:library
{:sym/head {:kind :poly :channels {...}}
:sym/blink {:kind :timeline :frames 3 :nodes [...]}}
```
A node with `:symbol :sym/blink` is an **instance**. Its own channels compose
*over* the symbol's, so one definition can be placed many times and tinted,
offset or retimed at each placement. A `:timeline` symbol has its own frame space,
which the instance's `:time` maps into — that is Flash's MovieClip, Lottie's
precomp and AE's pre-comp, and it is how a three-frame blink gets reused at
frames 40, 88 and 200 without copying it.
This is also where `docs/design.md`'s "closed vocabulary is right for the head"
lands: a plate library is a set of `:sym/head-*` definitions, and the strip
chooses which instance is placed on which frame. The take format's `plate=` field
becomes an instance reference.
## Evaluating a frame
```clojure
(defn eval-frame
"Scene at clip frame f -> draw ops in z order. Pure."
[scene f] ...)
```
1. Walk nodes in **topological order** by parent depth (cached; recompute only
when parentage changes).
2. Skip nodes outside `:span`.
3. Apply the node's time map to get its own local frame `fn`.
4. **Sample** each channel at `fn`: a map lookup for framed, a sorted-index
lookup for keyed, an array read for dense. Then apply `:over` layers.
5. Compose `world` from the parent's.
6. Transform geometry into raster space, writing into a **preallocated buffer**
owned by the node.
7. Emit `{:kind :poly :pts buf :n 20 :color idx :stencil id}`.
8. Sort by resolved `z`.
The op list is the boundary with stage 7 in `docs/architecture.md`: the
rasteriser takes ops and knows nothing about nodes, channels or time.
### Making it fast in CLJS
Three things, and only these three matter:
- **Decomposed and persistent for storage; flat and mutable for evaluation.**
Composed transforms are 6-element `Float64Array`s, not maps. Every renderer
does this; the storage form and the evaluation form are allowed to differ.
- **A cursor per channel.** Playback is sequential, so "most recent key at or
before `f`" is an advance of a saved index, O(1) amortised. Binary search only
on a seek. This is the difference between a `rsubseq` allocation per channel per
frame and none.
- **Preallocated point buffers per node.** Fixed topology means the size is known
at freeze time, so a frame allocates nothing. At 30fps, per-frame allocation is
the only thing that will make this stutter.
### What is in app-db, and what is not
| In app-db (tier 1) | In tier 2, behind a handle |
| --- | --- |
| nodes, parentage, z, spans, stencils | dense channel blocks |
| channel definitions, `:interp`, `:generated` | analysis artifacts |
| **framed** values, **keyed** keys, `:over` layers | preallocated eval buffers |
| library / symbol definitions | composed transform scratch |
The rule: **anything a human placed is in the document; anything a generator
produced is a handle.** Which is the same line `docs/architecture.md` draws for
sync and baking, arrived at again from the renderer's side.
## The current parts, in this model
Proof that it covers what exists, not just what is wanted:
| Now | Becomes |
| --- | --- |
| `mouth` outer ring, every frame | node `:mouth`, `[:geom :pts]` dense, `:generated {:by :roto/lips-outer}` |
| `mouth_in`, hidden below aperture | node `:mouth-in`, parent `:mouth`, `[:geom :pts]` dense + `[:vis]` dense |
| `teeth` from image content | node `:teeth`, stencil `:mouth-in`, `[:geom :pts]` dense, `:generated {:by :interior/teeth}` |
| lid rings | nodes `:lid-r/-l`, `[:geom :pts]` dense |
| lash line (`offsetRing`) | not data — a stage-6 parameter on the node, `{:grow px}` |
| iris disc | node `:iris-r`, `:kind :disc`, parent `:lid-r`, stencil `:sclera-r`, `[:xform :pos]` dense (quantised at freeze), radius framed |
| square pupil | node `:pupil-r`, `:kind :rect`, parent `:iris-r`, stencil `:iris-r` |
| brow ring + quantised raise | node `:brow-r`, `[:geom :pts]` dense (the traced ring with height removed), `[:xform :pos]` dense (the quantised raise). **The decomposition design.md insists on is two channels.** |
| head plate, kept frames | node `:head`, `:symbol` per instance, keys on `[:symbol]` at kept frames |
| painted background cel | node per layer, `[:geom :pts]` **framed**, `[:style :color]` framed |
| `mouth lead` | `:time {:offset k}` on performance nodes only |
| `exposure` | `:time {:expose n}` on the clip root, inherited |
| hand correction | an `:over` layer, `:offset` or `:replace` |
The brow row is the one worth looking at twice. `docs/design.md` argues at length
that the traced ring already contains the height, so the quantised raise must be
measured *out* and put *back* or the brow moves twice. In this model that is not
an argument to remember — it is two channels on one node, and getting it wrong
would mean writing the height into both.
## Format on disk and on the wire
Tier 1 is EDN/transit: the node tree, channel definitions, framed values, keys,
layers, library. Kilobytes, human-readable, diffable, and leaf-addressable for
sync.
Dense blocks are separate content-addressed binaries — `Int16Array` for raster
geometry, `Float32Array` for transforms — with a small header naming the channel
path, frame count and stride.
**Not Lottie internally**, despite the property shape being borrowed from it.
Lottie has no palette-indexed colour, its shapes are bezier with in/out tangents
where these are integer polygons, and its interpolation defaults are the opposite
of what is wanted. It is a good **export** target later, next to the `.take`
writer, and a bad internal format.
## Deferred
- **Per-key easing.** The structure allows it; nothing should use it until a
parented transform on a painted cel asks for it.
- **More than two channel layers.** The `:over` vector is already a list; a real
blend stack with weights is the NLA, and it is not needed to fix a bad frame.
- **Skew beyond the field.** `[:xform :skew]` is in the transform and in the
composition order from the start, because adding a component to a decomposition
later means migrating every stored transform.
- **Instance channel overrides on symbols.** Compose-over is specified; only
colour and transform need it at first.
- **Constraints and drivers.** Blender's other half. A gaze that aims at a null
object is the obvious first one, and it is a long way off.

869
docs/architecture.md Normal file
View file

@ -0,0 +1,869 @@
# arthur — structure
How the suite is laid out once it is a suite: ClojureScript and re-frame, a
clip as the unit of work, keying as its own step, and painting as a peer of
rotoscoping rather than a sketch bolted to the side.
`docs/design.md` says what the thing is and why. This says where the code goes.
`docs/animation-model.md` specifies the data both of them are about — nodes,
channels, symbols and time maps — and supersedes this document wherever the two
describe the same type.
Nothing here revises an aesthetic decision; several things here split a
decision that is currently made in two places at once.
## What is actually wrong with the current shape
The pure modules are fine. `pipeline.js`, `mathutil.js`, `landmarks.js`,
`interior.js`, `raster.js` and `take.js` are functions over data with the
reasoning written down next to them, and they port nearly verbatim.
`app.js` is the whole problem, and not because it is long. It is long because
six unrelated jobs are braided together in it:
- reading knob values out of the DOM (`opts()`),
- deriving everything from them (`rebuild`, `buildEyes`, `buildBrows`,
`resolveTeeth`),
- resolving what is on screen at a frame (`plateIndex`, `perfIndex`, `leadIndex`),
- rasterising (`renderFrame`, `compositeRender`),
- driving the clock (`tick`),
- persisting (`saveCels`, `loadCels`, `exportTake`).
Every one of those is a different layer, and `rebuild` recomputes all of them
whenever any knob moves. That is affordable at one clip and 72 frames and is
not affordable at a project. The re-frame port is worth doing chiefly because
the subscription graph *is* the staged dataflow `docs/design.md` already
describes — "three artifacts, not two" is a dependency graph written in prose.
## Three frame spaces, named
Most of the future bugs live here, so name them before anything else.
| Space | Symbol | Meaning |
| --- | --- | --- |
| **source frame** | `sf` | Index into the footage and the dense track. What detection produced. |
| **clip frame** | `cf` | 0-based within a clip. `cf = sf - (:in clip)`. |
| **sequence frame** | `qf` | Position on the timeline. `qf = (:at clip) + cf`. |
**Nothing authored is ever stored in sequence-frame space.** Keys, cels,
overrides and kept-frame sets are all in clip-frame space, so a clip slides on
the timeline without a single stored number changing. Exposure and lead are
transforms *within* clip space:
```clojure
(defn pose-frame [clip cf]
(-> cf (expose (:exposure clip)) (shift (:lead clip) (count-frames clip))))
```
which is the composition `app.js` currently performs in `leadIndex` and
`perfIndex` with the order implicit. Expose first, then lead: exposure floors
onto a grid and lead deliberately reads the future, so leading first and then
flooring would quietly discard the lead on most frames.
## The entity model
```
project
├── palette authored ramp; indices, never RGB
├── character plate library, per-plate slots
├── footage[] frames + audio + manifest (fps, w, h)
├── analysis[] dense track, cached, keyed by footage+model+version
├── clip[] the unit of work
│ ├── source footage id, analysis id, in/out in sf
│ ├── exposure, lead timing, in cf
│ ├── scene node tree
│ ├── channels node+property -> keyframe stream, in cf
│ ├── overrides (node, property, cf) -> value, applied last
│ └── cels painted vector layers, per exposure slot
└── sequence[] clip placements: {clip-id, at, in, out}
```
`clip` is the entity the current app has exactly one of and never names. Giving
it a name is most of the work: `state` in `app.js` is a clip with its analysis
inlined and its palette global.
### Two things called "track"
`docs/design.md` says "dense track" for the landmark stream. A timeline also
wants tracks. Pick now: the landmark stream is **analysis**, a keyframe stream
is a **channel**, and the word *track* is reserved for a row on the timeline.
Renaming this later costs a day.
## The node, decomposed
`take.js` today gives a part a `parent` and a `clip`, and `parent` is doing
nothing except documenting intent — `mouth_in` is already in the same
head-local raster space as `mouth`, so composing its transform would be
composing identity. The moment a painted cel is attached to a head plate that
moves, transform composition becomes real, and the three ideas currently sharing
two fields have to come apart:
| Field | What it does | Wrong to conflate because |
| --- | --- | --- |
| `:parent` | Transform composition. Child geometry is in parent's local space. | A node can be drawn over its parent without inheriting its motion. |
| `:stencil` | Colour-key clip, the `clip=` of the take format. | The iris is stencilled by the sclera and parented to the lid ring; those are different nodes. |
| `:z` | Draw order. | Order is authored per scene, not implied by the tree. |
A node is then:
```clojure
{:id :eye-r/iris
:source {:kind :primitive ...} ; the five kinds from design.md
:parent :eye-r/lid
:stencil :eye-r/sclera
:z 42
:color :iris ; palette key, never a hex
:interp :hold}
```
`:source` carries the taxonomy `docs/design.md` already has — `:plate`,
`:feature`, `:interior`, `:primitive`, `:scalar` — plus `:cel` for painted
vectors and `:group` for a pure transform node. That is the decomposition that
makes painting a peer rather than an annex: `paint.js` is not a special case,
it is a source kind whose channels happen to be authored by hand instead of
measured. `docs/design.md` already promises "pluggable sources"; this is the
data shape that keeps the promise.
Each node also gets an optional local transform channel — translate, rotate,
scale, squash. That is the thing the current tool cannot express and that the
`slot_mouth` / `scale` / `rot` / `squash` fields in `take.js` are reaching for.
## The flow, in seven stages
The user-facing flow is `frames -> analysis -> keying -> geometry -> palette`.
Analysis is three stages, not one, and the split is the most load-bearing
decision in this document, because **exactly one boundary is a cache
boundary.**
| # | Stage | In | Out | Cost |
| --- | --- | --- | --- | --- |
| 1 | **ingest** | video | footage: frames, audio, manifest | minutes, in-app |
| 2 | **detect** | footage | raw landmarks per frame | minutes, **cached** |
| 3 | **measure** | landmarks | anchor fit, residual, head-local rings, signals, interior pixels | seconds |
| 4 | **condition** | measurements | smoothed transforms and contours | milliseconds |
| 5 | **key** | conditioned signals + policy | channels: sparse keys, quantised holds, kept frames | milliseconds |
| 6 | **resolve** | channels + scene + exposure + lead + overrides | geometry per output frame | per frame |
| 7 | **palette** | geometry | indexed raster | per frame |
Stage 2 is the only artifact worth persisting and the only one worth a progress
bar. Stage 3 reads pixels — teeth extraction is here, and that is why it is
here rather than in keying: **after stage 3, nothing downstream may look at a
source pixel.** Stage 4 is separated from 3 only because `contour avg` and
`anchor avg` are knobs and the rest of stage 3 is not; splitting them means
dragging that slider does not re-run the interior extraction.
### Where the current code lands
| Now | Stage |
| --- | --- |
| `stabilize` | 3 measure |
| `eyeSignals`, `browSignals`, `pairIrises`, `pairBrows` | 3 measure |
| `extractTeeth` | 3 measure — it reads pixels |
| `smoothTransforms`, `smoothContours` | 4 condition |
| `selectKeys`, `suggestPlateFrames` | 5 key |
| `quantizeSnap`, `resolveBlink` | 5 key — a held value *is* a key |
| `exposeIndex`, `shiftIndex`, `activeKey`, `heldFrame` | 6 resolve |
| `toRasterRing`, iris placement at ring slots, `offsetRing` | 6 resolve |
| `IndexedRaster`, `drawCel` | 7 palette |
| `drawRegistered`, `posterizeInto` | reference only, off to the side |
| `writeTake` | serialization, off the end |
Three things in `app.js` currently straddle a boundary, and each straddle is a
bug waiting for a bigger project:
- **`resolveTeeth`** extracts from pixels *and* applies contrast/dwell/blob
knobs. Extraction is stage 3 and cached with the track; resolution is stage 5.
Today, nudging `teeth dwell` re-runs Otsu over every frame.
- **`buildEyes`** measures, quantises, *and* places the iris on the subsampled
lid ring. Those are stages 3, 5 and 6. The placement argument in
`docs/design.md` — that the iris is read off the already-smoothed ring — is a
stage-6 fact and stays true; it just needs to be *in* stage 6.
- **`buildBrows`** likewise: measure the height out of the ring (3), quantise
it (5), put it back (6). The doc's warning about moving the brow twice is
precisely a warning about these being one function.
### What each knob invalidates
This table is the argument for the split, and it should be derivable from the
sub graph rather than maintained by hand.
| Knob | Invalidates from |
| --- | --- |
| model / footage | 2 detect |
| `teeth contrast`, `cavity erode`, `blob grow`, `tongue reject`, `prefer upper` | 3 measure (mouth crop only) |
| `contour avg`, `anchor avg` | 4 condition |
| `teeth dwell`, `blink cut/hold/dwell`, `gaze step/dwell`, `brow step/dwell`, `suggest tolerance` | 5 key |
| `vertices`, `eye vertices`, `brow vertices`, `teeth vertices` | 5 key |
| `exposure`, `mouth lead`, kept-frame edits, overrides | 6 resolve |
| `lash line`, `iris size`, `pupil`, `brow weight` | 6 resolve |
| palette edits, colour assignment | 7 palette |
## Namespaces
```
src/arthur/
domain/ pure data, specs, ops. No re-frame, no DOM.
palette.cljs ramp; index lookup; the no-RGB rule as a spec
landmarks.cljs index tables (port verbatim)
ring.cljs ordered traversal: subsample, offset, simplicity
geom.cljs similarity fit, procrustes, moving average
node.cljs scene node: source, parent, stencil, z
scene.cljs node tree: topo order, transform composition
channel.cljs keyframe stream: active-key-at, hold semantics
clip.cljs clip entity; the frame-space conversions
cel.cljs painted vector layers
timeline.cljs sequence: clip placement, qf <-> cf
take.cljs take-file writer (port take.js)
flow/
ingest.cljs
detect.cljs the MediaPipe boundary, and the only one
measure/
anchor.cljs stabilise: procrustes, similarity, residual
mouth.cljs
eyes.cljs openness, gaze, iris pairing vote
brows.cljs raise/tilt, ring and end pairing votes
interior.cljs otsu, morphology, components, radial contour
condition.cljs
key.cljs velocity minima, dwell, quantise, blink, decimate
resolve.cljs scene eval at a frame; exposure, lead, overrides
raster.cljs indexed scanline fill, stencil, disc, rect
reference.cljs registered underlay, posterise
db.cljs app-db schema + spec
store.cljs handles for things too big for app-db
clock.cljs audio clock, outside app-db
events/ one ns per domain area
subs/ one ns per stage
ui/
shell.cljs
mode/ one ns per tool mode
panel/ strip, worksheet, readouts, palette, layers
canvas.cljs the one imperative sink
fx/ mediapipe, files, audio, persistence, export
```
Two rules about this tree. `domain/` may not require `flow/`, and neither may
require `re-frame`. And `flow/` namespaces take explicit arguments — no
namespace below `subs/` ever calls `subscribe`.
## app-db, and what is not in it
**app-db holds authored data and ids. Nothing derived, and nothing large.**
That sounds like ordinary hygiene and it is not: it is the precondition for two
features that are otherwise unbuildable.
- **Spec validation on every event.** Worth having, affordable only over
authored data.
- **Cheap writes.** Every edit `assoc`es into app-db, and every mounted layer-2
sub compares the result. Both are structural-sharing operations and stay cheap
only while the map is small.
Undo is *not* on that list, and an earlier draft said it was. See Collaboration:
snapshot-based undo is right single-player and wrong once two people edit.
So the dense track and the decoded frames live in `arthur.store`, a `defonce`
map of id to JS object, and app-db holds `{:analysis/id "sha-..."}`. Landmarks
stay as they arrive — arrays of `{x, y, z}`, or better, a flat `Float32Array` —
and are *not* converted to Clojure maps. 478 points × 600 frames is 286,800
maps, allocated for nothing: no sub ever needs to diff them, and every stage
that touches them walks all of them anyway.
### The playhead, and where per-frame work goes
A reaction propagates downstream only when its own **output value** changes, not
when one of its inputs notifies. So with layer-2 extractors and layer-3
computations, a playhead tick costs one cheap extractor run per mounted layer-2
sub, each of which returns the same value for every subtree the tick did not
touch and therefore notifies nobody. `::measurements`, `::channels` and
`::resolver` do not re-run. The `:<-` graph is what buys that, and it buys it
whether or not the playhead is in app-db.
So **the playhead lives in app-db**, like everything else. An earlier draft of
this document put it in a standalone ratom to avoid an invalidation storm that
does not happen. Two independent reasons it belongs in db:
- `[:playback/seek ...]` in the event log is how scrubbing becomes inspectable
in re-frame-10x.
- **A collaborator's playhead is a feature.** Putting it outside app-db puts it
outside the machinery that shares it. See Collaboration.
What is true is narrower, and is about sub authoring and interceptors rather
than about the playhead:
- **Expensive work must not live in a layer-2 sub.** A sub that derefs app-db
directly re-runs on every db change whatever changed. That is the actual
content of the folklore about re-frame and canvas.
- **Do not put a per-frame event behind a global interceptor that walks the
whole db** — a spec-validating `after`, or `std-interceptors/debug` and its
`clojure.data/diff`. At 30fps that is thirty full-db traversals a second. The
transport event carries its own interceptor chain and is excluded from the
global ones.
- **The render sink is not a Reagent component.** Stage 7 writes bytes into a
canvas from an rAF loop; it is not a view that re-renders.
Stage 6 is still arranged so the per-frame path is a lookup, not a computation:
```clojure
;; recomputes when channels, scene, exposure, lead or overrides change
(rf/reg-sub ::resolver ...) ;; => (fn [cf] geometry), or an index into a bake
;; the rAF loop: reads, blits, dispatches nothing
(defn tick []
(let [cf (clock/clip-frame)]
(raster/paint! (@resolver cf))
(canvas/blit!)))
```
The sub produces a *resolver*; the loop applies it. A knob change costs one sub
recomputation; a frame costs a lookup and a blit.
## Events and subs
Events are named for intent, and carry the clip they apply to:
```clojure
[:clip/set-exposure clip-id 2]
[:clip/toggle-kept clip-id cf]
[:keying/re-key clip-id] ; not a setter; policy unchanged, redo the work
[:keying/suggest clip-id]
[:paint/commit-shape clip-id node-id cel-frame pts]
[:node/set-parent clip-id node-id parent-id]
[:override/set clip-id node-id :pts cf value]
[:timeline/move-clip seq-id clip-id qf]
```
Setters are fine where the intent *is* the value — `set-exposure` is honest.
Where they differ, name the intent: `:keying/re-key` takes no value and is not
`:clip/set-keys`.
Subs mirror the stages one-to-one, so the graph and the table above are the
same object:
```
::footage -> ::landmarks -> ::measurements -> ::conditioned -> ::channels -> ::resolved -> ::raster
```
Each is a layer-3 sub over the previous plus the parameters for its stage only.
That is what makes "drag `exposure`" recompute `::resolved` and nothing above it.
## Tool modes
A mode is a namespace, not a branch. Each exposes a map:
```clojure
{:id :paint/pen
:cursor "crosshair"
:keymap {"Enter" [:paint/close-shape] "Escape" [:paint/abort]}
:on-pointer (fn [ev ctx] ...)
:overlay (fn [g ctx] ...) ; draws handles, never output pixels
:enter :exit (fn [ctx] ...)}
```
with two rules worth stating because the current `PaintUI` breaks both and gets
away with it at this size:
- **In-progress gesture state is mode-local**, in a ratom the mode owns — not in
app-db. An unclosed polygon must not appear in the undo history, and a drag
must produce one undo entry rather than one per pointermove. The mode
dispatches a single `:paint/commit-shape` on release.
- **Overlays draw to a separate canvas.** Handles, vertex boxes and the green
close-target are not indexed pixels and must never be in the raster. The
current code composites them into the same context as the output, which is
fine for a preview and lies to you the moment you want to judge the look.
The palette-index constraint and grid snapping stay where they are: they belong
to `domain/cel` and `domain/palette`, enforced at commit, so no tool can bypass
them.
## Where things live
Superseded in detail by Serialization and Baking below; the local picture is:
| Thing | Where | Keyed by |
| --- | --- | --- |
| project (tier 1) | server, plus a local autosave copy | project id, then leaf path |
| footage frames, audio (tier 3) | on disk / OPFS, untouched | content hash |
| analysis artifact, geometry bakes (tier 2) | OPFS, with IndexedDB for the index | content hash over every input |
| render output | never persisted | — |
The cache key on the analysis artifact must include the **detector version**. A
model upgrade that silently reuses old landmarks presents as "the tool got worse"
with no event to attach it to — which is the argument for content-addressing
rather than version-numbering everything derived.
## Baking
"No analysis during playback" is `docs/design.md`'s three-artifact rule with a
number attached. The expensive stages are 2 (detect), 3 (measure) and, over a
long take, 4 (condition). Stages 5–7 are arithmetic over small arrays. So there
are two bakes, not one, and they answer different questions.
### Bake A — the analysis artifact. Mandatory.
Stages 2–4 for one footage at one set of conditioning parameters. This is what
must never run while the transport is moving.
| Field | Shape |
| --- | --- |
| landmarks | `Float32Array[n_frames × 478 × 2]` |
| transforms | `Float32Array[n_frames × 4]` — s, θ, tx, ty |
| residual | `Float32Array[n_frames]` |
| head-local rings | `Int16Array` per ring table, isotropic fixed point |
| signals | openness, gaze, brow ends, aperture — `Float32Array[n_frames]` each |
| **mouth crops** | `Uint8Array`, one small crop per frame |
The mouth crops are the non-obvious entry, and they are what makes remote work
possible at all. `extractTeeth` reads source pixels, so without them a
collaborator holding the analysis but not the 600 source PNGs cannot touch a
single teeth knob. A 40×30 crop is about 1.2KB; a 600-frame take is under a
megabyte against hundreds for the footage.
### Bake B — resolved geometry. For scale.
Stage 6 output per node. Not needed for one clip — resolving a frame is a key
lookup and a transform — and needed the moment six roto tracks are live at 30fps.
**Fixed topology is what makes this flat.** Because every key of a part carries
the same vertex count with the same vertex meanings, a node's whole bake is a
rectangular array with no per-frame header and no indirection:
```
pts Int16Array[n_frames × n_verts × 2] raster space, already grid-snapped
state Uint8Array[n_frames] hidden flag + palette index
```
Frame `f` of a node is the subarray at `f * n_verts * 2`. 600 frames × 20 verts
is 48KB; a twelve-node clip about 600KB; six roto tracks about 3.5MB. So this is
the payoff on the aesthetic constraint rather than a cost of it — a variable
vertex count would force a per-frame offset table and a scan.
### Rasters are not baked
Stage 7 is the cheapest stage and the one most worth keeping live: palette and
colour assignment are the last decisions and the ones you want to change in
motion. Baking to pixels would freeze exactly the loop that should be instant.
Small raster *thumbnails* for the strip and the timeline are a separate artifact
with a separate cache.
### Unbaking is not an inverse
The bake never replaces its input. Authored state — channels, params, overrides —
is always retained and always authoritative, so "unbake this roto and edit it" is
a boolean about which side of the cache the renderer reads, not a computation.
Instant by construction. Three rules keep it that way:
- **No destructive bake.** Nothing is discarded when something is baked.
- **Hand corrections never go into the bake.** They are `:overrides`, applied at
stage 6 *after* the bake is read, so a correction survives a rebake. This is
`docs/design.md`'s "hand corrections must not live in the take", and it is the
rule that stops baking from becoming a trap.
- **Bakes are content-addressed** by a hash over every input that produced them:
footage hash, detector version, and the parameters of each stage at or above
the bake line. A stale bake is then unreachable rather than wrong, and a
collaborator's bake is fetchable by the same key — see Collaboration.
### Partial bakes
Bake per node, per frame range. Scrubbing into an unbaked range resolves on
demand and fills the cache; a worker bakes ahead of the playhead. Show it: a
bake-state bar under the timeline is an affordance every NLE has already taught
people to read, and it is honest about what is ready.
## Serialization, in three tiers
Cut by mutability and size. The cut is what makes collaboration and baking both
tractable, because it decides what is allowed on the wire.
| Tier | What | Size | Synced | Undoable |
| --- | --- | --- | --- | --- |
| 1 **authored** | palette, clips, scene graphs, channels, cels, overrides, sequences | KB | yes — it *is* the document | yes |
| 2 **derived** | analysis artifacts, geometry bakes, thumbnails | MB | no — content-addressed blobs, fetched | no |
| 3 **source** | frames, audio | MB–GB | no — immutable, by hash | no |
Tier 3 is produced **by the app**, not by `extract.sh`: wasm ffmpeg decodes the
clip to frames and audio in the browser, and they are uploaded and served as
content-addressed blobs. That makes tier 3 the same kind of thing as tier 2 — a
cache with a hash — and it removes the one step that currently needs a terminal.
Two consequences worth planning for: the extraction rate is recorded by the code
that chose it rather than by a `manifest.json` a human might edit, and **offline
work needs the frames already fetched**, so the local blob cache is what makes a
plane usable. Decoding server-side instead is the same data model with a
different worker; the format does not care which side runs it.
**Only tier 1 is the document.** A bake inside the shared document is a system
that puts 48KB on the wire per vertex drag, and it is unnecessary, because tier 2
is a pure function of tiers 1 and 3.
Tier 1 stays small enough to read. The only vertex data in it is what a human
placed by hand: cel polygons and overrides. Everything traced is tier 2.
### Keys are a map, not a vector
```clojure
;; not this
{:keys [{:f 0 :pts [...]} {:f 4 :pts [...]}]}
;; this
{:keys {0 {:pts [...]}, 4 {:pts [...]}}}
```
`activeKey` becomes a lookup in a sorted map rather than a scan; a single key
becomes addressable as a path; and two people keying different frames of one part
merge field-wise with no merge algorithm at all. `selectKeys` returns an array
today — change it before anything depends on the order.
## Collaboration
`../tl` already has the model, and it is the right one to copy:
- **An authoritative server, and one broadcast source.** Edits go over HTTP; the
server persists and then broadcasts the delta to the room. The socket is
read-only for document state. No OT, no CRDT.
- **Presence gossiped between peers** on the same socket, with the server doing
exactly two things: handing out a connection id, and stamping the sender's
server-side identity onto every message so nobody can post as somebody else.
- **Parties as a pure function of the roster**, so every peer computes the same
answer and the server knows nothing about them.
- **Revisions**: one snapshot of the *authored layer* per save, with a user and a
summary.
That maps onto the tiers exactly — the server stores tier 1 and snapshots tier 1,
with tiers 2 and 3 as content-addressed blobs beside it.
### Why this model, and not a CRDT
The usual reason to reach for Yjs or Automerge is automatic convergence without
conflict dialogs. **In this domain the primary contested data type is a vector
path, and automatic convergence of a vector path is undesirable** — two people
dragging vertices on one polygon merge into a shape neither drew. So the CRDT's
headline benefit is neutralised on exactly the data that needs help, and the
parts where it would work fine (scalars, maps keyed by frame) are the parts that
LWW already handles.
What a CRDT would additionally cost here is not the library, it is the second
source of truth: the canonical document moves into an opaque blob, and every
server-side thing that reads the document — revisions, collaborator lists,
thumbnail generation, the admin, any query — needs it materialised back out with
`y-py` or a Node sidecar. That is real operational weight bought for a feature
this domain does not want.
So: authoritative server, HTTP writes, LWW on addressed leaves, presence on the
side. And **writes stay on HTTP rather than moving to the socket**, which is
tl's choice and the right one: auth, idempotency, status codes, retries and
conditional requests all come for free, and a dropped socket cannot lose a
write. Latency is not the reason to move them, because latency is handled on the
client (below) and never by waiting for a round trip.
### The model is standard; only tl's plumbing is hand-rolled
Worth separating. *Optimistic concurrency control over addressed resources with
an entity tag* is RFC 7232 — `ETag`, `If-Match`, `412`/`409` — and it is what
databases and HTTP APIs have done for thirty years. It is not a bespoke
invention, and every HTTP client implements its half. The hand-rolled part of tl
is the plumbing around it, so the move is to keep the model and replace the
plumbing with the boring standard wherever one exists.
### Four things to add
tl's implementation is missing two pieces that hand-rolled sync usually omits,
and arthur needs two more that tl does not because its write rate is low.
1. **Conditional writes.** `PUT /project/:id/leaf/<path>` with `If-Match: <etag>`,
answering `409` on a stale write. tl's PUT replaces, so the loser's work
disappears silently. For a knob that is survivable; for a painted cel it is
the class of bug that ends trust in a tool. The 409 body carries the current
value so the client can offer keep-mine / take-theirs.
2. **A monotonic project version, and gap detection.** The broadcast is
fire-and-forget, so a peer that misses one delta — queue pressure, a
reconnect gap — is silently stale forever. Every delta carries `seq`; a client
that sees `seq > local + 1` refetches. This is what makes staleness
self-healing instead of permanent, and it is about twenty lines.
3. **Gesture coalescing, client side.** This is the one real load difference
between tl and arthur: tl's user annotates a clip every few seconds, arthur's
user drags a vertex. A drag is **one** write on release, not one per
pointermove. With the gesture held in mode-local state and committed once (see
Tool modes), arthur's write rate is comparable to tl's and the model holds
unchanged. Without it, no sync model survives.
4. **An outbox, so nothing ever waits on the network.** "No lag" is satisfied
entirely on the client: apply locally, enqueue, reconcile on ack.
```clojure
{:pending {"clip/7/cel/bg/12" {:value … :etag … :tries 0}}}
```
With one rule that is easy to get wrong: **while a leaf has a pending local
write, incoming broadcasts for that leaf are ignored.** Otherwise a remote
delta lands mid-gesture and the shape snaps back and forth between the two
values until the ack arrives.
### Two operational notes
- tl's `ROOMS` is process-local, as its own comment says. That is a single-worker
ceiling, not a design flaw, and it has to move to Redis before a second worker.
- **Revisions need a coarser trigger than every save.** tl snapshots a small
annotation layer; arthur's tier 1 contains cel polygons, so a snapshot per save
will bloat the table. Snapshot on an explicit "mark version", or time-boxed.
### When this model is wrong, and what replaces it
State the tripwire rather than trusting the judgement: **if 409s on cel leaves
become common in real use, the merge unit is too coarse.** The answer then is a
finer unit, not a CRDT, and there is a planned progression:
| Step | Merge unit | Gets you |
| --- | --- | --- |
| now | the cel (`cel/:node/:frame`) | two people on different frames |
| next | the layer | two people on different layers of one cel |
| last | **the stroke** — an append-only list of immutable stroke records | two people painting one layer at once |
The third step is worth seeing now because it is the escape hatch that keeps
this decision from being a dead end. Once a drawing is a list of immutable
records rather than a mutable point array, concurrent painting merges by
construction — two people appending different records cannot conflict — and that
is a domain-specific version of what a CRDT would have given, without the second
source of truth. It is also a plausible thing to want for undo granularity
anyway.
### Make the merge unit small instead of clever
Last-writer-wins clobbers only when its unit is too big. tl can save a scene;
arthur cannot save a project, because one vertex drag would then clobber a
collaborator's keying. The fix is addressing, not an algorithm:
```
palette
sequence/:sid
clip/:cid/timing exposure, lead, kept frames
clip/:cid/params/:area teeth | eyes | brows | mouth | plate
clip/:cid/node/:nid one node: source, parent, stencil, z, colour
clip/:cid/channel/:nid/:prop
clip/:cid/cel/:nid/:frame
clip/:cid/overrides/:nid/:prop
```
An earlier draft of this list had `params` and `scene` as one leaf each, and both
were too coarse: one person tuning teeth while another tunes eyes would have
collided on every slider move, and two people adding nodes would have collided
always. Split params **by feature area** and give every node its own leaf. With
fractional `:z` there is no separate order leaf to contend on, which is the
second thing fractional indices buy.
Each path is a **leaf**: independently addressed, independently versioned, LWW
with an `If-Match` on its version. The boundaries are chosen so the things people
actually do simultaneously land on different leaves — two people painting
different cels, or keying different parts, never meet. Within a channel leaf the
server merges **field-wise by frame**, which is the return on keys-as-a-map and
about fifteen lines of Python.
### Where it stays honest about conflict
| Data | Concurrency | Resolution |
| --- | --- | --- |
| palette, clip params, knobs | scalar | LWW on the leaf. Two people tuning one clip will fight — that is a real thing to surface, not to merge. |
| channels | map by frame | field-wise union, LWW per frame |
| kept-frame set | add / remove | store `{frame → bool}`, not a set, so removals merge instead of vanishing |
| cel layer order, `:z` | list insert | **fractional indices, not integers** |
| cel polygon points | sequence | LWW on the whole layer, plus an advisory lease |
| parentage | tree | LWW per node, with a cycle check that rejects the losing move |
| playhead, selection, tool | ephemeral | presence only — never in the document, never in history |
Two of those are cheap now and expensive later.
**Fractional indices for anything ordered.** Integer `:z` and integer layer
positions do not survive concurrent insertion: you get duplicates and gaps. A
fractional key between neighbours makes concurrent inserts commute. Adopt it in
`domain/node` and `domain/cel` from the first commit.
**Do not merge one polygon between two people.** Two simultaneous edits to the
same path have no meaningful merge, and the result is a shape nobody drew. The
layer is the unit, LWW, and presence shows who is on it before the collision.
A coarse merge unit that is *visible* beats a fine one that invents geometry.
### Presence carries more than a cursor
tl's roster entry, extended with what arthur's views need:
```clojure
{:cid "…" :user "…" :party nil :joinable true
:clip cid :frame cf :playing? false :rate 1.0
:node :mouth :tool :paint/pen
:editing "clip/7/cel/bg/12"} ; advisory lease on a leaf
```
`:editing` is what makes leaf-level LWW liveable — the collision becomes visible
before it happens. Advisory only: no server enforcement, so there is no lock to
leak when a tab closes.
### Follow mode, and why frames never go on the wire
tl's parties become arthur's follow mode nearly unchanged: mirror `:clip`,
`:frame`, `:playing?` and the plate mode.
**Broadcast transport state, never frames.** On play, send
`{:clip :frame :playing? :rate}` once; each peer's own audio element then becomes
its own clock and the picture follows it exactly as it does locally. Per-frame
messages would put thirty packets a second on the wire to reproduce something
every peer can compute. A peer whose bake is cold shows a cold indicator and
catches up rather than stalling the party.
### Going offline
Arthur is offline-capable almost by accident, and it is worth noticing why: tier
1 is small enough to hold entirely in app-db, tier 2 is a local content-addressed
cache, and tier 3 is a directory of PNGs that `extract.sh` put on your own disk.
So when the network drops, **everything needed to scrub, key, paint, resolve and
render is already local.** Nothing about playback or editing touches the server.
What stops is only the three things that are inherently remote: your writes stop
acking, others' deltas stop arriving, and presence goes dark.
Two preconditions, or the above is a lie:
- **The outbox must be durable.** In memory, a closed tab loses the session. It
goes in IndexedDB, written in the same task as the optimistic local apply —
a few hundred microseconds, invisible, and the difference between "offline is
fine" and "offline is a trap".
- **Vendor MediaPipe's wasm.** `README.md` notes that `face_landmarker.task` is
local but the wasm bundle is fetched from jsdelivr on first use. That makes
stage 2 the one thing that silently requires a network, and it will be
discovered on a plane. Vendor it or precache it in a service worker.
### Reconnecting
Refetch the document first, then drain the outbox — in that order, so conflicts
are detected against current state rather than against a stale etag. Since the
document is kilobytes, a full refetch is cheaper than reasoning about a long
`seq` gap.
Then each pending write lands in one of four cases, and the design goal is that
only the last one reaches a human:
| Case | Resolution |
| --- | --- |
| nobody touched your leaves | etags still match, outbox drains, converged. The common case. |
| a **channel** leaf collided | **auto-merges** field-wise by frame — union the frames, LWW per frame. No human. |
| a **scalar** leaf collided (knob, timing) | a compact list with one "keep all mine / take all theirs", because a knob is one number and the value is visible in the output anyway |
| a **cel** leaf collided | see below — never a dialog |
The channel row is the third time keys-as-a-map pays for itself, and it is the
reason a long offline session usually reconciles with no interaction at all.
### A colliding cel forks into a layer
Do not ask an artist to choose between two drawings in a modal. **Keep both:**
their cel stays where it is, and yours is added on top as a layer named for the
session that made it. Nothing is lost, nothing is silently clobbered, and the
conflict is resolved by looking at it and deleting a layer — which is an
operation the paint tool already has, and which is what an artist would do
anyway.
This is the general principle for this whole class of conflict: **when the merge
unit is coarse, resolve by stacking rather than by choosing.** A stack is
reversible and legible; a choice made in a dialog against two thumbnails is
neither.
### The awkward cases, named
- **A write to a leaf whose parent was deleted** — a node or clip removed while
you were away. The server answers `404`; the client parks it in an orphaned-edits
tray rather than dropping it. Rare, and the alternative is silent loss.
- **A very long offline session against a busy project.** Merging may be the
wrong frame entirely. Offer the escape hatch revisions already make possible:
save the offline session as a revision, take theirs, and reconcile by hand from
two named versions. Better than a hundred-row conflict list.
- **Two peers both run detection offline.** Both compute the same analysis
artifact and both try to upload it. Content addressing makes that idempotent —
same hash, same bytes — so let it race rather than coordinating a claim.
### Undo is per-user — and this revises an earlier claim
An earlier section of this document said undo was free if app-db held only
authored data, via `day8.re-frame/undo`. That is true single-player and wrong the
moment two people edit: a snapshot of app-db reverts their work along with yours.
So undo is a **per-user stack of inverse leaf writes**, applied as ordinary
edits. Your undo writes a leaf back to the value you last saw, and it conflicts
with a concurrent editor in the same visible way any other write does. Keeping
app-db small is still right — for validation cost and allocation — but it is no
longer the undo story.
## The 30fps budget
At 320×200 stage 7 is not the problem. An even-odd scanline fill over 64,000
pixels with a dozen mostly-small parts is well under 200,000 byte writes a frame:
six million a second at 30fps. Six roto tracks off baked geometry adds a handful
of subarray reads.
Two things in the current code *will* miss 30fps, and neither is the rasteriser:
- **`posterizeInto` does a `getImageData` every frame.** A GPU→CPU readback
stalls the pipeline, and it is followed by 64,000 linear scans over the palette
to find the nearest colour — around a million distance computations a frame.
Both are avoidable: posterise once per source frame into a cached `Uint8Array`
(it is a pure function of the frame, the transform and the palette), and
replace the nearest-colour scan with a 5-5-5 RGB lookup table — 32KB, built
once per palette.
- **`drawRegistered` draws a full-resolution photo every frame.** Correct as a
drawing reference on a held frame; not something to do thirty times a second.
Pre-scale each source frame to raster size once, at load.
The rule both are instances of: **nothing that reads source pixels may run on the
transport path.** That is the same line the bake boundary draws, which is why
drawing it once, in stage 3, pays twice.
## Porting order
Tests first, and not as discipline — as an oracle.
1. **Port `synth.js` and `selftest.js` first**, to `cljs.test`. The synthetic
track is the only fixture with ground truth, and the 105 assertions encode
invariants that are invisible to inspection: the ring-simplicity check, the
bowtie, the iris-pairing vote fed a deliberately swapped track.
2. **Run both implementations on the same synthetic track and diff numerically**
while porting each pure module. `fitSimilarity` and `procrustesMean` should
agree to 1e-9; a disagreement is a port bug, not float noise.
3. Port `landmarks`, `geom`, `ring`, `raster`, `take` — mechanical, and they are
where the invariant comments live. **Carry the comments across verbatim.**
They are the most valuable text in the repo and every one of them is a bug
that already happened.
4. Port `flow/measure/*` by lifting out of `pipeline.js`, then split
`condition` and `key` out of it.
5. Build `db`, `store`, `clock`, and stage 6 + 7 against a single hardcoded
clip. At this point the synthetic take should render, with no UI beyond a
canvas and a play button.
6. Only then the UI, the modes, the timeline.
7. Add the ring-simplicity and no-RGB checks as specs, so they fire on authored
data too and not only in tests.
Three things are cheap in step 3 and expensive after step 6, so they go in early
even though nothing needs them yet: **keys as a map** keyed by frame, **fractional
indices** on `:z` and cel layer order, and **leaf addressing** for tier 1 — the
paths under Collaboration, used as the shape of app-db even while single-player.
None of them cost anything on day one and all three are retrofits that touch
every namespace.
The wiring cross-check in `selftest.js` — every `el('id')` must exist in
`index.html` — becomes unnecessary and should be deleted rather than ported. It
is a test for a failure mode that Reagent does not have.
## Decisions I would defer
- **Whether `raster` stays JS.** 320×200 is 64,000 pixels and CLJS over a
`Uint8Array` with no seq allocation handles it comfortably; port it and
measure. Do not port it into idiomatic `map`/`reduce`.
- **Interpolation.** `:hold` is the only interp the aesthetic wants, and the
channel model should still carry `:interp` per node, because a parented
transform on a painted cel is the one place a tween might be right. Do not
implement it until something needs it.
- **Multi-character scenes.** `character` is in the entity model as one field
and should stay a stub until there are two.
- **Audio per clip vs per sequence.** One master track is right until it is not.
- **Whether bake B exists at all in v1.** One clip does not need it; the flat
typed-array layout should be designed now and built when the second roto track
appears.
- **Sharing tier 2.** Content addressing means a collaborator *can* fetch a
bake instead of recomputing, and also means nothing breaks if they do not.
Ship the cache-miss path first and treat blob sharing as an optimisation —
the analysis artifact is the only one where it clearly pays, because it is the
only one that costs minutes.

321
docs/port-plan.md Normal file
View file

@ -0,0 +1,321 @@
# arthur — port plan and handoff
Self-contained. You should not need any prior conversation to execute this.
## What arthur is
A tool that turns live-action video into 2D animation that reads as
hand-authored: flat polygons, a tiny indexed palette, hard edges, 320×200, no
antialiasing, motion carried by silhouette. It tracks a face out of a clip,
reduces the lip contour to a handful of vertices, derives teeth from image
content, and renders flat indexed fills.
It currently works, as vanilla JS ES modules with no build step. `python3
serve.py`, open `127.0.0.1:8777`. **Synthetic take** exercises everything below
detection with no video needed.
This plan converts it to ClojureScript + re-frame, restructured around one
uniform animation data model, and adds a Django backend for persistence and
(later) collaboration.
## Status of the existing documents
| File | What it is | Authority |
| --- | --- | --- |
| `js/**` | the working tool, ~4,800 lines | **authoritative.** The comments encode bugs that actually happened. |
| `docs/animation-model.md` | the target data model: nodes, channels, symbols, time maps | build to this |
| `docs/architecture.md` | module layout, stages, sync and baking design | build to this; much of it is future scope |
| `docs/design.md`, `README.md` | prior synthesis by an earlier agent | useful, **not authoritative**. Revise freely. Do not treat its aesthetic claims as settled requirements. |
Where a document and the code disagree, the code wins, and the invariant list
below is lifted from the code for exactly that reason.
## Target repo layout
Both halves live here. Django at the root, because `manage.py` at the root is the
convention and keeps every `python manage.py` invocation working with no `cd`.
```
arthur/
mise.toml toolchain for both halves
manage.py
requirements.txt
server/ Django project: settings, urls, asgi, wsgi
clips/ Django app: models, views, consumers, routing, migrations
frontend/ the CLJS app
shadow-cljs.edn
package.json
src/arthur/** namespace root stays arthur.* whatever the dir is called
test/arthur/**
static/arthur/js/ shadow-cljs output, collected by Django staticfiles
docs/
js/ index.html serve.py extract.sh the old tool — see "the oracle"
```
`clips` is a naming call, not a constraint — it is the Django app holding
Project, Clip, Footage, Analysis, Leaf and Revision. Rename in one line if
something fits better.
Dev runs two processes: Django serves the page, `shadow-cljs watch app` rebuilds
into `static/arthur/js`. Set `:output-dir "../static/arthur/js"` in
`shadow-cljs.edn`.
## Toolchain
`mise install` from the repo root. `mise.toml` pins java 21+, node 20, clojure,
python 3.12, and creates `.venv`.
Verified to resolve cleanly: `reagent 1.2.0`, `re-frame 1.4.3`, current
shadow-cljs.
## Scope
**In:** analysis → keyframes → playback. The pure numeric core, the animation
data model, a player, the measurement stages, and freezing measurements into
channels.
**Out, and do not build it:** paint and cels; `suggest` (it only decides which
frames get a hand-drawn cel, so it has no job until drawing exists); the timeline
and sequences; symbols and the plate library; multiplayer; the override layer.
Each is designed for in `docs/architecture.md` and `docs/animation-model.md`.
Leave the `:over` field present and empty; leave `:symbol` out entirely.
## The data model
Full specification in `docs/animation-model.md`. The subset to build:
```clojure
;; The scene is a flat map of id -> node. Parent pointers, never nested maps.
{:id :mouth :kind :poly :parent :head :z "a3" :stencil nil :span [0 240]
:time {:mode :inherit} ; or {:mode :map :expose 2 :offset -1 :rate 1.0}
:channels
{[:xform :pos] {:animated? false :value [0.0 0.0]}
[:xform :rot] {:animated? false :value 0.0}
[:xform :scale] {:animated? false :value [1.0 1.0]}
[:xform :skew] {:animated? false :value [0.0 0.0]}
[:xform :anchor] {:animated? false :value [0.0 0.0]}
[:geom :pts] {:animated? true :interp :hold
:dense {:store "sha256:…" :offset 0 :stride 40 :frames 600}
:generated {:by :roto/lips-outer :analysis "sha256:…"
:params {:verts 8 :contour-avg 1}}
:over []}
[:style :color] {:animated? false :value :skin-dark}
[:vis] {:animated? false :value true}}}
```
Three channel shapes, one accessor `(value-at channel f)`:
- `{:animated? false :value v}` — static. A thing that simply exists.
- `{:animated? true :interp :hold :keys {0 v, 4 v}}` — sparse, authored, in the
document. **Keys are a map by frame, never a vector.** Store a plain map
(transit loses sortedness) and build the sorted index in the resolver.
- `{:animated? true :interp :hold :dense {...}}` — generated, one value per
frame, in a typed array outside app-db.
`:generated` is provenance and **the renderer never reads it.** It is what the UI
uses to offer a parameter panel instead of raw keys. It lives on the *channel*,
not the node, because a node wants a rotoscoped `[:geom :pts]` and a
hand-animated `[:xform :pos]` at the same time.
`:skew`, `:span`, `:anchor` and `:over` stay in the shape even though nothing
drives them yet: each is a component of a decomposition or of a composition
order, and adding one later migrates every stored transform.
Transform composition, per node:
```
local = T(pos) · T(anchor) · R(rot) · K(skew) · S(scale) · T(-anchor)
world = world(parent) · local
```
## What the prototype knows that you would otherwise rediscover
**The JS is a prototype.** Its conclusions about what looks right are provisional
and you may revisit any of them; several contradict each other already. But a few
things in it are not taste — they are facts about MediaPipe, about the maths, or
about what an operation means — and those cost real time to rediscover.
### Mechanical. Getting these wrong produces wrong output, not a different look.
1. **MediaPipe's normalised space is anisotropic.** It divides x by image *width*
and y by *height*, so equal numbers do not mean equal pixels. Multiply x by
`aspect = W/H` before any fit, or a "similarity" fitted in that space is not
one and head roll comes out subtly wrong. When mapping pixels for an underlay,
**both** axes divide by `imgH`.
2. **MediaPipe's left/right naming is viewer-relative in some places and
subject-relative in others.** Any left/right pairing read off a table is a coin
flip, and a swap looks *almost* right — each eye still has an iris roughly
where it belongs — so it survives inspection. Resolve it from geometry.
3. **Ring tables are ordered traversals**, and slot position is the vertex's
identity. That is what makes temporal correspondence possible at all, whatever
you decide the shapes should look like. `subsampleSlots` returns ring
*positions*, not landmark ids.
4. **A wrongly-ordered ring self-intersects, and it is invisible at odd vertex
budgets and obvious at even ones.** If you keep ordered rings, assert
simplicity in a test; no amount of looking will catch it reliably.
5. **Scaling a ring to thicken it collapses when the ring is degenerate** — a shut
eyelid scaled by 1.1 is still shut, so the lash line vanishes on exactly the
frames where it is the whole drawing. A fixed radial offset does not. Maths,
not taste.
6. **A fractional centre for a small integer-sized shape changes its size.**
Round the origin, not the extents, or a 3px mark is 3px on one frame and 4px on
the next.
7. **Order of operations on time:** flooring onto a grid and shifting against the
clock do not commute. Shift first and the floor discards it on most frames.
### Choices the prototype made. Revisit freely; here is what each was for.
| Choice | Its stated reason | How you would learn it was wrong |
| --- | --- | --- |
| similarity (4 DOF), not affine | extra DOF absorbs out-of-plane head rotation as shear and smears it into the mouth | the residual readout stops responding to head turn |
| reference is the Procrustes mean over the shot, not frame 0 | no single frame's idiosyncrasies get baked into every other | one frame's detection error biases the whole take |
| smooth the transform, not the contour | sparse keys at velocity minima rejected detector noise for free | it was already broken by a "bounded exception" once keys went dense, so it was never a law |
| gaze measured against the eye's corner midpoint | measured against the lid, every blink drags the origin down and fakes a glance at the floor | gaze correlates with blinks |
| one gaze shared by both eyes | at this size the per-eye difference is noise, and independent noise reads as wall-eyed | a wink or a real vergence is lost |
| hold, never interpolate | a tweened mouth reads as puppet software | motion looks stepped rather than snappy |
| palette indices, never sampled RGB | sampling colour produces a pixel-art filter irrecoverably | — |
These are where to look first if the output is wrong. They are also where to look
first if you want to change the look.
## Conventions
- `domain/*` may not require `flow/*`; neither may require `re-frame`.
- Every flow function is `(f params inputs) -> output`. No state, no db, no atoms.
- Nothing below `subs/` calls `subscribe`.
- Every analysis function that reads pixels takes a `debug?` flag and returns its
intermediate masks alongside its result, the way
`interior.js/extractTeeth(..., wantDebug)` already does.
- Port the invariant comments across verbatim. They are the most valuable text in
the repo.
## The oracle
**Keep `js/`, `index.html` and `serve.py` in the tree through step 5.** They cost
nothing, `serve.py` still runs the old tool, and they are the numeric oracle:
run both implementations on the same synthetic track and diff.
`fit-similarity` and `procrustes-mean` should agree to **1e-9**; a larger gap is a
port bug, not float noise.
**Parity proves the port is faithful, not that the answer is right.** The JS is a
prototype, so keep the two kinds of test apart: a *parity* test pins behaviour
while you move it, and is deleted once the move is done; a *correctness* test
asserts something you have decided you want, and stays. Conflating them bakes the
prototype's mistakes into the rewrite and makes them permanent. Delete them in one commit once the CLJS player renders
the synthetic take correctly.
**Do not port the debug views** (`drawPanes`, `drawInteriorDebug`,
`drawEyeOverlay`, `drawGazeDebug` in `js/app.js`). The knowledge in them is not
the canvas calls — it is *which things you must see to tune teeth*: the source
crop, the in-region mask, the surviving mask, and the local contour. That contract
already exists as `extractTeeth(..., wantDebug)` returning
`debugCanvas(src, inReg, mask, pw, ph, local)`. **Port the payload, skip the
drawing.** Redrawing it is ten lines whenever it is wanted.
## Steps
Each step ends somewhere runnable. Do not proceed past a step whose "done" does
not hold.
### 0 — scaffold and the oracle
`mise install`. Create `frontend/` with shadow-cljs, reagent, re-frame. Port
`synth.js` (the synthetic landmark generator, including its `swapIris` flag) and
the numeric assertions from `selftest.js` to `cljs.test`.
**Done:** the suite runs and fails informatively.
### 1 — the pure bottom
Port verbatim: `landmarks.js` → `domain/landmarks`, `mathutil.js` → `domain/geom`,
ring helpers → `domain/ring`, `raster.js` → `domain/raster`, the palette →
`domain/palette`.
**Done:** tests pass, including ring simplicity and the swapped-iris vote. Numeric
agreement with the JS to 1e-9. Nothing renders.
### 2 — the data model, with no analysis in it
`domain/channel` (`value-at` across all three shapes, plus a per-channel cursor),
`domain/node` (transform composition), `domain/scene` (topological order by parent
depth, `eval-frame` → draw ops in z order).
Hand-write a scene in EDN — a rectangle parented to a group whose
`[:xform :pos]` is keyed on four frames — and render it through `domain/raster`
into a canvas.
This is deliberately before any analysis. **The data model has never been
validated; find out here**, with fifty lines to throw away, rather than after
porting nine hundred lines of measurement into a shape that does not work.
**Done:** something moves on screen.
### 3 — the player
`clock` (audio-clocked: `frame = ⌊currentTime · fps⌋`, so a slow loop drops frames
instead of drifting; ½× and ¼× come free from `playbackRate`), the rAF loop, a
`::resolver` sub, and transport UI.
The loop reads and blits and **dispatches nothing**. The sub yields a resolver
closure; the loop applies it at the playhead. The playhead itself lives in app-db
like everything else — with layer-2 extractors and layer-3 computations, a
playhead tick does not invalidate the expensive stages.
**Done:** the hand-written scene plays at 30fps against audio, scrubs, and runs at
½× and ¼×.
### 4 — measure: anchor and mouth
Port `stabilize` and the lip rings out of `pipeline.js`. Split **condition**
(`smoothTransforms`, `smoothContours`) into its own stage so the two smoothing
knobs do not re-run measurement.
**Done:** measured numbers match the JS on the synthetic track.
### 5 — freeze
The new module, and the heart of this work: measurements → channels. A dense
`[:geom :pts]` block per node — `Int16Array[frames × verts × 2]`, raster space,
grid-snapped — with `:generated` attached. Fixed topology is what makes this a
rectangular array with no per-frame header.
**Done:** the synthetic take plays back as a moving mouth. Full vertical slice.
### 6 — detect
MediaPipe interop behind one namespace; real frames, real audio, real fps from
the manifest. **Vendor the wasm** rather than fetching from jsdelivr — it is
currently the only thing in the tool that silently requires a network.
**Done:** real footage plays back as a rotoscoped mouth.
### 7 — the rest of measure
Eyes (openness, gaze, iris pairing vote, blink resolution with its `hold`), brows
(raise and tilt at both ends, both correspondence votes), interior (otsu,
morphology, components, radial contour). Each keeps its `debug?` payload.
Two things fall out of the model instead of being written: the brow's
measure-the-height-out-and-put-it-back is `[:geom :pts]` plus `[:xform :pos]`, two
channels on one node; and the iris is a `:disc` node parented to the lid ring and
stencilled by the sclera.
**Done:** parity with the JS tool, minus paint.
### 8 — knobs
The parameter UI, as leaf-addressed params in app-db
(`clip/:cid/params/:subject/:area` — see below), so the sync layer added later has
nothing to retrofit.
### 9 — backend and take export
Django project, the `clips` app, models for Project/Clip/Footage/Analysis/Leaf,
and project load/save. Port `take.js` — sixty-five lines, and it is the proof the
model serialises.
## Two things to not foreclose
The feature controls will later be rethought to handle more than one face,
periodic occlusion, stable identity across frames, and feature groups with their
own parameters. That design can wait; two decisions here are free now and
annoying to reverse:
- **Presence is not visibility.** An occluded subject has *no value* on a frame,
which is different from a part being hidden. Give every dense block a
`Uint8Array` state mask per frame and let it mean *absent* as well as hidden.
- **Params carry a subject segment.** `clip/:cid/params/:subject/:area`, with one
subject today. Adding a path segment later touches every read and write.
The identity tracker, when it comes, should use the same pattern the iris and brow
correspondences already use: vote across every frame rather than trusting one.

76
frontend/README.md Normal file
View file

@ -0,0 +1,76 @@
# frontend
The ClojureScript half. See `docs/port-plan.md` for what is being built and in
what order; this file is only how to run it.
## Once
```sh
mise install # from the REPO ROOT: java 21+, node 20, clojure, python
cd frontend && npm install
```
`java` must be 21+. On an older JDK shadow-cljs fails with "CompilerOptions has
been compiled by a more recent version of the Java Runtime", which reads like a
shadow-cljs bug and is not one. `mise install` is what prevents it.
## The tests
```sh
cd frontend && npm test
```
That is three things in order: regenerate the JS oracle's answers, compile the
`:test` build, run it under node.
```
node test/parity/oracle.mjs # drives js/ and writes test/parity/oracle.json
shadow-cljs compile test
node out/node-tests.js
```
Run them separately if a compile error is in the way. The oracle JSON is
generated, not committed.
## The app
```sh
cd frontend && npx shadow-cljs watch app
```
Then open **<http://localhost:8778/index.html>** — with the `/index.html`, not
bare `/`. This shadow-cljs does no directory-index resolution, so `/` is a 404
whatever the roots are.
Through step 1 the page is a placeholder on purpose: there is nothing to render
until the data model exists, and a shell built before the model is a shell built
around a guess. Something moves on screen at step 2.
Port 8778 is deliberately not 8777. `python3 serve.py` from the repo root still
runs the old JS tool on 8777, and the two are meant to run side by side — that is
the whole reason `js/` is still in the tree.
From step 9 Django serves the page and `:dev-http` goes away.
## The oracle
`js/` is the numeric oracle, not dead weight. `test/parity/` runs both
implementations on the same synthetic track and diffs them: `fit-similarity` and
`procrustes-mean` agree to 1e-9, the raster pixel-for-pixel.
Both sides get the identical track because `js/synth.js` reads `Math.random` at
call time, so `oracle.mjs` stubs it to a constant and the CLJS side passes
`:rand-fn (constantly 0.5)`. `js/` itself is never modified.
**`test/parity/` and `arthur.parity-test` get deleted in one commit at step 5.**
A parity test pins behaviour while code moves; keeping it afterwards would bake
the prototype's mistakes into the rewrite and make them permanent.
## Layout
```
src/arthur/domain/ pure. No re-frame, no DOM, no flow/.
test/arthur/synth.cljs the synthetic track — test infrastructure, not src
test/parity/ the JS oracle harness. Deletable at step 5.
public/index.html dev host page. Django replaces it at step 9.
```

1624
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
frontend/package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "arthur-frontend",
"private": true,
"version": "0.0.1",
"scripts": {
"watch": "shadow-cljs watch app",
"release": "shadow-cljs release app",
"test": "node test/parity/oracle.mjs && shadow-cljs compile test && node out/node-tests.js"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"shadow-cljs": "^2.28.21"
}
}

View file

@ -0,0 +1,24 @@
<!doctype html>
<!-- Host page for the CLJS build, for development only.
In production Django renders this and pulls the same bundle out of
staticfiles, which is why the script src is the /static/ path already. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>arthur</title>
<style>
:root { color-scheme: dark; --bg: #12141c; --fg: #c9c3b4; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); }
body { font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
main { padding: 24px; }
/* The preview is nearest-neighbour everywhere. A browser that smooths the
upscale would misrepresent the look the tool exists to judge. */
canvas { image-rendering: pixelated; }
</style>
</head>
<body>
<div id="app"></div>
<script src="/static/arthur/js/main.js"></script>
</body>
</html>

41
frontend/shadow-cljs.edn Normal file
View file

@ -0,0 +1,41 @@
;; Two builds and no more:
;;
;; app the tool. Output goes straight into the Django staticfiles tree, so
;; `python manage.py runserver` and `shadow-cljs watch app` are the whole
;; dev loop with nothing copying files between them.
;; test :node-test, because everything below `ui/` and `fx/` is pure and has
;; no business needing a browser to be asserted about. The canvas-facing
;; parts get asserted through domain/raster's byte buffer instead, which
;; is what the JS selftest already did.
{:source-paths ["src" "test"]
:dependencies [[reagent "1.2.0"]
[re-frame "1.4.3"]]
;; Dev server for the CLJS half, on a different port from serve.py (8777) so the
;; old tool and the port can run side by side — which is the whole point of
;; keeping js/ around as the numeric oracle.
;;
;; Two roots: `public` holds the host page, `..` is the repo root so the bundle
;; at /static/arthur/js/ resolves, and so manifest.json, audio.wav and frames/
;; are reachable when step 6 needs real footage.
;;
;; THE ORDER IS LOAD-BEARING. The repo root has an index.html too — the old
;; tool's — so with `..` first, /index.html would quietly serve the prototype
;; instead of the port. That would look like the CLJS build having regressed to
;; a suspiciously complete tool rather than like a misconfigured server.
;;
;; Open /index.html, not /. This shadow-cljs does no directory-index resolution,
;; so bare / is a 404 whatever the roots are. Django serves the page from step 9
;; and this whole key goes away.
:dev-http {8778 ["public" ".."]}
:builds
{:app {:target :browser
:output-dir "../static/arthur/js"
:asset-path "/static/arthur/js"
:modules {:main {:init-fn arthur.core/init}}}
:test {:target :node-test
:output-to "out/node-tests.js"
:ns-regexp "-test$"}}}

View file

@ -0,0 +1,19 @@
(ns arthur.core
"The app's one entry point. Deliberately almost empty until port-plan step 2:
there is nothing to render until the data model exists, and a shell built
before the model would be a shell built around a guess."
(:require [reagent.dom.client :as rdc]))
(defonce root (atom nil))
(defn shell []
[:main
[:h1 "arthur"]
[:p "Scaffold only. The scene renderer arrives with domain/scene."]])
(defn ^:dev/after-load mount []
(rdc/render @root [shell]))
(defn init []
(reset! root (rdc/create-root (js/document.getElementById "app")))
(mount))

View file

@ -0,0 +1,130 @@
(ns arthur.domain.geom
"2D similarity transforms and temporal smoothing.
A transform is {:s :theta :tx :ty}; a point is {:x :y}. Both stay maps at this
layer: this is the numeric oracle the JS is diffed against, and a faithful
port is worth more here than a fast one. The dense typed-array
representations appear at the freeze boundary, not below it.")
(defn fit-similarity
"Least-squares similarity (translation + rotation + uniform scale, 4 DOF)
mapping P onto Q. Closed form; no iteration.
Deliberately NOT affine or homography: the extra degrees of freedom absorb
out-of-plane head rotation as shear/perspective and smear it into the mouth.
Four DOF removes exactly translation, roll and depth-scale, and leaves yaw and
pitch as a measurable residual."
[P Q]
(let [n (count P)
[pcx pcy qcx qcy]
(loop [i 0, pcx 0.0, pcy 0.0, qcx 0.0, qcy 0.0]
(if (< i n)
(recur (inc i)
(+ pcx (:x (nth P i))) (+ pcy (:y (nth P i)))
(+ qcx (:x (nth Q i))) (+ qcy (:y (nth Q i))))
[(/ pcx n) (/ pcy n) (/ qcx n) (/ qcy n)]))
[a b norm]
(loop [i 0, a 0.0, b 0.0, norm 0.0]
(if (< i n)
(let [px (- (:x (nth P i)) pcx) py (- (:y (nth P i)) pcy)
qx (- (:x (nth Q i)) qcx) qy (- (:y (nth Q i)) qcy)]
(recur (inc i)
(+ a (+ (* px qx) (* py qy))) ; dot
(+ b (- (* px qy) (* py qx))) ; cross
(+ norm (+ (* px px) (* py py)))))
[a b norm]))
theta (js/Math.atan2 b a)
;; A degenerate configuration has nothing to recover a scale from. Fall
;; back to 1 rather than dividing by zero: one bad detection frame would
;; otherwise poison the Procrustes mean and therefore every frame.
s (if (> norm 1e-12) (/ (js/Math.hypot a b) norm) 1)
c (js/Math.cos theta)
sn (js/Math.sin theta)]
{:s s
:theta theta
:tx (- qcx (* s (- (* c pcx) (* sn pcy))))
:ty (- qcy (* s (+ (* sn pcx) (* c pcy))))}))
(defn apply-sim [tf p]
(let [c (js/Math.cos (:theta tf))
sn (js/Math.sin (:theta tf))]
{:x (+ (* (:s tf) (- (* c (:x p)) (* sn (:y p)))) (:tx tf))
:y (+ (* (:s tf) (+ (* sn (:x p)) (* c (:y p)))) (:ty tf))}))
(defn apply-sim-all [tf pts]
(mapv #(apply-sim tf %) pts))
(defn fit-residual
"Residual RMS after the fit, in the units of Q. Rises with out-of-plane
rotation, so it is the signal for \"this section is not stabilisable\"."
[tf P Q]
(let [n (count P)]
(js/Math.sqrt
(/ (loop [i 0, acc 0.0]
(if (< i n)
(let [m (apply-sim tf (nth P i))
q (nth Q i)]
(recur (inc i)
(+ acc (js/Math.pow (- (:x m) (:x q)) 2)
(js/Math.pow (- (:y m) (:y q)) 2))))
acc))
n))))
(defn procrustes-mean
"Generalised Procrustes: the reference is the MEAN rigid configuration over the
shot, not frame zero, so no single frame's idiosyncrasies get baked into every
other frame. Three passes is plenty."
([frames-rigid] (procrustes-mean frames-rigid 3))
([frames-rigid iters]
(let [n (count frames-rigid)]
(loop [ref (mapv (fn [p] {:x (:x p) :y (:y p)}) (nth frames-rigid 0))
iter 0]
(if (= iter iters)
ref
(recur
(->> frames-rigid
(reduce (fn [acc rig]
(let [moved (apply-sim-all (fit-similarity rig ref) rig)]
(mapv (fn [a m] {:x (+ (:x a) (:x m)) :y (+ (:y a) (:y m))})
acc moved)))
(mapv (constantly {:x 0.0 :y 0.0}) ref))
(mapv (fn [p] {:x (/ (:x p) n) :y (/ (:y p) n)})))
(inc iter)))))))
(defn moving-average
"`radius` is in frames either side: 0 is off, 1 averages over 3 frames, 2 over
5. Expressed as a radius rather than a window so that \"off\" is 0 and every
value is symmetric - an even window would be lopsided in time."
[vals radius]
(if (<= radius 0)
(vec vals)
(let [v (vec vals)
n (count v)
half (js/Math.floor radius)]
(mapv (fn [i]
;; Clamped at the ends rather than shortened, so every output is an
;; average of the same COUNT of samples and the first frame is not
;; noisier than the rest.
(let [lo (- i half) hi (+ i half)]
(/ (reduce + (map (fn [j] (nth v (min (dec n) (max 0 j))))
(range lo (inc hi))))
(inc (- hi lo)))))
(range n)))))
(defn smooth-transforms
"Smooth the four transform parameters, NEVER the contour. Landmark jitter of a
pixel is smeared into the mouth by the inverse transform, so the transform is
where the low-pass belongs; smoothing the contour would destroy the
performance, which is the entire asset.
Angles are smoothed as (cos, sin) so wrapping cannot produce a spike."
[tfs radius]
(let [c (moving-average (map #(js/Math.cos (:theta %)) tfs) radius)
sn (moving-average (map #(js/Math.sin (:theta %)) tfs) radius)
s (moving-average (map :s tfs) radius)
tx (moving-average (map :tx tfs) radius)
ty (moving-average (map :ty tfs) radius)]
(mapv (fn [i] {:theta (js/Math.atan2 (nth sn i) (nth c i))
:s (nth s i)
:tx (nth tx i)
:ty (nth ty i)})
(range (count tfs)))))

View file

@ -0,0 +1,115 @@
(ns arthur.domain.landmarks
"MediaPipe FaceLandmarker index tables.
Ring vectors are ORDERED traversals, not raw connection sets: vertex position
within a ring is the vertex's identity, and every downstream stage depends on
that ordering being stable. See docs/design.md, \"Fixed topology\".
Tables only. The operations over a ring — subsample, offset, simplicity —
live in arthur.domain.ring, because they are about ordered traversals in
general and know nothing about faces.")
;; Rigid landmarks for the similarity fit. Eye corners, nose bridge, nose tip.
;; Nothing here may be a feature that moves under performance: including the
;; mouth or brows bleeds performance into the stabilization.
(def RIGID [33 133 362 263 168 6 1])
;; Outer lip ring, clockwise from the right corner over the top.
;; index 0 = right corner, 5 = top centre, 10 = left corner, 15 = bottom centre.
(def LIPS-OUTER
[61 185 40 39 37 0 267 269 270 409
291 375 321 405 314 17 84 181 91 146])
;; Inner lip ring, same orientation and the same four cardinal positions.
(def LIPS-INNER
[78 191 80 81 82 13 312 311 310 415
308 324 318 402 317 14 87 178 88 95])
;; Inner upper / lower lip centres. Their separation is the aperture signal that
;; decides whether the mouth interior is present at all.
(def APERTURE [13 14])
;; Face oval, used only to derive the placeholder plate in v1.
(def FACE-OVAL
[10 338 297 332 284 251 389 356 454 323 361 288
397 365 379 378 400 377 152 148 176 149 150 136
172 58 132 93 234 127 162 21 54 103 67 109])
;; Eye corners, for the calibration box and for reporting fit residual.
(def EYE-INNER [133 362])
;; ---- eyes ----
;;
;; Eyelid rings, under the same contract as the lip rings: ORDERED traversals
;; where slot position IS vertex identity. Both eyes start at the OUTER corner
;; and go over the UPPER lid first, so slot k means the same anatomy on both
;; sides. On a 16-slot ring that puts the four cardinals exactly on the four
;; quarter slots - 0 outer corner, 4 upper lid centre, 8 inner corner, 12 lower
;; lid centre - so every even vertex budget lands on real landmarks.
;;
;; The two rings traverse opposite directions on screen, because they are
;; mirrored anatomy described the same way. Nothing downstream cares: an
;; even-odd fill has no winding, and ring SIMPLICITY is what is asserted.
(def EYE-R-RING
[33 246 161 160 159 158 157 173
133 155 154 153 145 144 163 7])
(def EYE-L-RING
[263 466 388 387 386 385 384 398
362 382 381 380 374 373 390 249])
;; Outer, inner corner per eye. All four are also in RIGID, and that is the
;; point: the eye's reference frame is built only from landmarks that do not
;; move under performance, so a blink cannot be mistaken for a change of gaze.
(def EYE-R-CORNERS [33 133])
(def EYE-L-CORNERS [263 362])
;; Upper and lower lid centres. Their separation over the corner distance is the
;; openness signal that decides whether the eye is shut - the same shape of
;; measurement as APERTURE is for the mouth, but normalised, so one threshold
;; carries across takes and faces.
(def EYE-R-LIDS [159 145])
(def EYE-L-LIDS [386 374])
;; The two iris blocks the refined mesh appends: centre first, then four ring
;; points. WHICH BLOCK BELONGS TO WHICH EYE IS NOT DECLARED HERE - MediaPipe's
;; own "left"/"right" is viewer-relative in some docs and subject-relative in
;; others, and a swap looks almost right, so it would survive an eyeball and
;; then read as a permanently wall-eyed character. flow/measure/eyes resolves it
;; from the geometry instead.
(def IRIS-A [468 469 470 471 472])
(def IRIS-B [473 474 475 476 477])
;; ---- brows ----
;;
;; Each brow is two five-point chains, an upper edge and a lower edge, which
;; close into a ten-point ring: out along one edge from the outer end to the
;; inner, back along the other.
;;
;; WHICH EDGE IS UPPER IS DELIBERATELY NOT DECLARED, and unlike the iris it does
;; not need to be. Swapping them traverses the same ring the other way round,
;; and an even-odd fill has no winding, so the shape is identical either way.
;; What the ring guarantees instead is that the two ENDS land on fixed slots:
;; 0 and 9 are one end, 4 and 5 the other. Averaging a pair therefore gives the
;; brow's height at that end whichever edge is on top, which is all the raise
;; and tilt measurement needs.
;;
;; Which end is the OUTER one is resolved from geometry in flow/measure/brows,
;; because getting it backwards mirrors the tilt - inner-up "worried" would
;; render as outer-up - and that is an expression error, not a glitch, so it
;; would read as a directed performance choice rather than as a bug.
(def BROW-A-RING
[70 63 105 66 107
55 65 52 53 46])
(def BROW-B-RING
[300 293 334 296 336
285 295 282 283 276])
;; The slots at each end of a brow ring, as pairs to average.
(def BROW-END-0 [0 9])
(def BROW-END-1 [4 5])
;; The number of landmarks the refined mesh emits: 468 face + 10 iris. Dense
;; frames are this long whether or not the iris blocks carry anything.
(def NUM-LANDMARKS 478)

View file

@ -0,0 +1,50 @@
(ns arthur.domain.palette
"The indexed palette.
THE RULE, and it is a rule rather than a default: a part carries a palette
INDEX, never a sampled RGB value. Sampling colour off the footage produces a
pixel-art filter, and it does so irrecoverably — once a shape holds a measured
colour there is no way back to an authored one, because the information that it
was ever a choice is gone. Every `[:style :color]` channel holds one of the
keywords below.
Entries are ordered, and the order IS the index the raster writes. Inserting in
the middle renumbers every stored index, so new tones append.")
(def entries
[{:name :bg :hex "#12141c"}
{:name :skin-base :hex "#b07a5a"}
{:name :skin-dark :hex "#7a4f3a"}
{:name :mouth-dark :hex "#24161a"}
{:name :teeth :hex "#d9cfc2"}
;; Sclera is not white, and that is authored, not measured. A true white at
;; 320x200 next to a warm skin ramp reads as a hole punched in the face; the
;; eye sits in a socket, in shadow, so it is a dimmer and cooler tone than the
;; teeth, which catch the light.
{:name :eye-white :hex "#c9c3b4"}
;; Three tones for the eye - sclera, iris, pupil - which is the "two or three
;; tones per part" budget, spent where it buys the most: an eye with no tonal
;; step inside it reads as a hole.
{:name :iris :hex "#4a5468"}
{:name :pupil :hex "#171a22"}
;; Brows get their own entry rather than sharing skin-dark with the lash line.
;; They are hair, not shadow: when hair plates exist they want to match those,
;; and tying them to the lash means you cannot change one without the other.
{:name :brow :hex "#3a2a22"}])
(def hexes (mapv :hex entries))
(def index-of
"Palette keyword -> the index the raster writes. Derived, so the vector above
is the single place an ordering is declared."
(into {} (map-indexed (fn [i e] [(:name e) i]) entries)))
(defn hex->rgb [hex]
(let [s (.replace hex "#" "")]
[(js/parseInt (.slice s 0 2) 16)
(js/parseInt (.slice s 2 4) 16)
(js/parseInt (.slice s 4 6) 16)]))
(def rgb
"Index -> [r g b], precomputed."
(mapv hex->rgb hexes))

View file

@ -0,0 +1,150 @@
(ns arthur.domain.raster
"Indexed flat-fill rasteriser.
Canvas2D antialiases path fills, and antialiasing is exactly what the target
idiom does not have: Animator Pro fills polygons into a 256-colour indexed
raster with hard edges (csd_render_poly). A preview that antialiases would
misrepresent the look it exists to judge, so this writes palette indices into
a byte buffer with an even-odd scanline fill and expands to RGBA only at the
very end.
A raster is {:w :h :buf} where :buf is a Uint8Array, and the fill functions
MUTATE it and return it. That is deliberate and it is the one place in domain/
that mutates: a persistent 64000-entry vector rebuilt per draw op per frame is
not a rasteriser. The mutation is confined — a raster is created, filled and
blitted inside one frame, and never stored in app-db.
No DOM here. `->rgba` returns plain bytes; wrapping them in an ImageData is
ui/canvas's job, which is also what lets every assertion below run in node.")
(defn make [w h]
{:w w :h h :buf (js/Uint8Array. (* w h))})
(defn clear! [{:keys [buf] :as r} index]
(.fill buf index)
r)
(defn fill-poly!
"Even-odd scanline fill. Samples at pixel centres (y + 0.5), so a polygon
edge landing exactly on a pixel boundary resolves consistently."
[{:keys [w h buf] :as r} pts index]
(let [n (count pts)]
(when (>= n 3)
(let [ys (map :y pts)
y0 (max 0 (js/Math.ceil (- (apply min ys) 0.5)))
y1 (min (dec h) (inc (js/Math.floor (- (apply max ys) 0.5))))]
(doseq [y (range y0 (inc y1))]
(let [sy (+ y 0.5)
xs (sort
(for [i (range n)
:let [a (nth pts i)
b (nth pts (mod (inc i) n))]
;; A horizontal edge contributes no crossing, and
;; dividing by its zero height would emit Infinity.
:when (not= (:y a) (:y b))
:let [lo (min (:y a) (:y b))
hi (max (:y a) (:y b))]
;; Half-open in y: >= lo and < hi. A vertex shared by
;; two edges is counted exactly once, so the parity
;; cannot flip at a corner and leak a whole scanline.
:when (and (>= sy lo) (< sy hi))]
(+ (:x a) (* (/ (- sy (:y a)) (- (:y b) (:y a)))
(- (:x b) (:x a))))))]
(when (>= (count xs) 2)
(doseq [[xa xb] (partition 2 xs)]
(let [x-from (max 0 (js/Math.ceil (- xa 0.5)))
x-to (min (dec w) (js/Math.floor (- xb 0.5)))
row (* y w)]
(loop [x x-from]
(when (<= x x-to)
(aset buf (+ row x) index)
(recur (inc x)))))))))))
r))
(defn fill-disc!
"`over` is an optional stencil: when given, only pixels that currently hold
that index are written. The indexed buffer is its own clip mask, which is
how Animator Pro would do it - and it is what keeps the iris inside the
eye. A disc clipped by the sclera cannot spill past the lid at any gaze or
any radius, including mid-blink when the opening is a two-pixel sliver, so
the lid crops the iris for free instead of the gaze range needing a
clamp that would flatten the performance at the extremes."
([r cx cy rad index] (fill-disc! r cx cy rad index nil))
([{:keys [w h buf] :as r} cx cy rad index over]
(let [rr (* rad rad)
y0 (max 0 (js/Math.floor (- cy rad)))
y1 (min (dec h) (js/Math.ceil (+ cy rad)))
x0 (max 0 (js/Math.floor (- cx rad)))
x1 (min (dec w) (js/Math.ceil (+ cx rad)))]
(loop [y y0]
(when (<= y y1)
(loop [x x0]
(when (<= x x1)
(let [dx (- (+ x 0.5) cx)
dy (- (+ y 0.5) cy)]
(when (<= (+ (* dx dx) (* dy dy)) rr)
(let [o (+ (* y w) x)]
(when (or (nil? over) (= (aget buf o) over))
(aset buf o index)))))
(recur (inc x))))
(recur (inc y))))
r)))
(defn fill-rect!
"An exactly size x size block of pixels, snapped to the pixel grid, with the
same optional stencil as fill-disc!.
The pupil is a SQUARE because at 320x200 it is three pixels across, and a
circle of radius 1.5 is not a circle - it is a plus sign with the corners
gnawed off, and it changes shape as it moves. A square that size is a
deliberate mark that stays the same mark wherever it lands, which is the
whole argument for flat shapes at this resolution.
The top-left is rounded rather than the centre, so the block is size x size
on every frame. Round the extents instead and a fractional centre gives you
three pixels on one frame and four on the next, which reads as the pupil
breathing."
([r cx cy size index] (fill-rect! r cx cy size index nil))
([{:keys [w h buf] :as r} cx cy size index over]
(when (>= size 1)
(let [x0 (js/Math.round (- cx (/ size 2)))
y0 (js/Math.round (- cy (/ size 2)))]
(loop [y (max 0 y0)]
(when (< y (min h (+ y0 size)))
(loop [x (max 0 x0)]
(when (< x (min w (+ x0 size)))
(let [o (+ (* y w) x)]
(when (or (nil? over) (= (aget buf o) over))
(aset buf o index)))
(recur (inc x))))
(recur (inc y))))))
r))
(defn ->rgba
"Expand indices through the palette at integer zoom. Nearest-neighbour by
construction, so no filtering softens the result.
Returns {:width :height :data} with :data a Uint8ClampedArray, ready to hand to
an ImageData. An index with no palette entry comes out magenta rather than
transparent or black: writing an index the palette does not have is a bug, and
it should be impossible to miss."
([r palette-rgb] (->rgba r palette-rgb 1))
([{:keys [w h buf]} palette-rgb zoom]
(let [W (* w zoom)
H (* h zoom)
d (js/Uint8ClampedArray. (* W H 4))]
(loop [y 0]
(when (< y H)
(let [srow (* (js/Math.floor (/ y zoom)) w)]
(loop [x 0]
(when (< x W)
(let [c (or (nth palette-rgb (aget buf (+ srow (js/Math.floor (/ x zoom)))) nil)
[255 0 255])
o (* (+ (* y W) x) 4)]
(aset d o (nth c 0))
(aset d (+ o 1) (nth c 1))
(aset d (+ o 2) (nth c 2))
(aset d (+ o 3) 255))
(recur (inc x)))))
(recur (inc y))))
{:width W :height H :data d})))

View file

@ -0,0 +1,96 @@
(ns arthur.domain.ring
"Operations on an ordered ring of points.
A ring here is a closed traversal: a vector of points where slot k means the
same thing on every frame of a shot. That is what makes temporal
correspondence possible at all, so nothing in here is allowed to reorder,
insert or adaptively decimate — every function is index-preserving or returns
slot positions.")
(defn subsample-slots
"Pick `n` slots from a ring of `len` by even spacing. Returns RING POSITIONS,
not landmark ids: positions are the vertex identity downstream, and mapping ids
back to positions with indexOf would silently pick the wrong slot if a table
ever repeated an id.
For even n this naturally lands on the cardinal positions (corners and lip
centres) of a 20-point ring. Fixed indices, never adaptive decimation: the
vertex at slot k means the same thing on every frame of the shot."
[len n]
(mapv (fn [k] (mod (js/Math.round (/ (* k len) n)) len)) (range n)))
(defn subsample-ring
"`subsample-slots` applied to a table, yielding landmark ids."
[ring n]
(mapv #(nth ring %) (subsample-slots (count ring) n)))
(defn offset-ring
"Push a ring outward from its centroid by a FIXED distance, not by a scale
factor.
Scaling collapses with the shape: a shut eyelid scaled by 1.1 is still a shut
eyelid, so the lash line - the only thing left to draw when the eye is closed
- would vanish exactly on the frames where it is the whole drawing. A fixed
radial offset gives a band of roughly constant thickness that survives the
ring going degenerate, and it keeps a star-shaped ring simple, which
docs/design.md requires of every cut part."
[pts d]
(if (or (nil? d) (zero? d))
pts
(let [n (count pts)
cx (/ (reduce + (map :x pts)) n)
cy (/ (reduce + (map :y pts)) n)]
(mapv (fn [p]
(let [dx (- (:x p) cx)
dy (- (:y p) cy)
m (js/Math.hypot dx dy)]
;; A vertex sitting exactly on the centroid has no outward
;; direction. Leave it where it is rather than emitting NaN and
;; poisoning the whole ring.
(if (< m 1e-9)
{:x (:x p) :y (:y p)}
{:x (+ (:x p) (* (/ dx m) d))
:y (+ (:y p) (* (/ dy m) d))})))
pts))))
(defn- orient
"Sign of the cross product (p->q) x (p->r): which side of pq the point r is on."
[p q r]
(js/Math.sign (- (* (- (:x q) (:x p)) (- (:y r) (:y p)))
(* (- (:y q) (:y p)) (- (:x r) (:x p))))))
(defn segments-cross?
"True when ab and cd cross properly. Collinear and touching cases are
deliberately NOT crossings: adjacent ring edges share an endpoint, and a
degenerate ring — the shut eyelid — has collinear ones. Reporting those would
make the simplicity assertion fire on exactly the shapes it has to allow."
[a b c d]
(let [o1 (orient a b c) o2 (orient a b d)
o3 (orient c d a) o4 (orient c d b)]
(and (not= o1 o2) (not= o3 o4)
(not (zero? o1)) (not (zero? o2))
(not (zero? o3)) (not (zero? o4)))))
(defn self-intersections
"Every pair of non-adjacent edges of the closed ring that cross, as [i j].
This exists because \"fixed topology\" is load-bearing: because hold parts CUT
between poses rather than interpolating, a ring whose vertex order is wrong
self-intersects and renders as blocks meeting at corners. It is invisible at
odd vertex counts and obvious at even ones, so it needs an assertion rather
than an eyeball."
[pts]
(let [n (count pts)
at (fn [i] (nth pts (mod i n)))]
(vec
(for [i (range n)
j (range (inc i) n)
:when (and (not= (mod (inc j) n) i)
(not= (mod (inc i) n) j))
:when (segments-cross? (at i) (at (inc i)) (at j) (at (inc j)))]
[i j]))))
(defn simple?
"True when no pair of non-adjacent edges crosses."
[pts]
(empty? (self-intersections pts)))

View file

@ -0,0 +1,157 @@
(ns arthur.domain.geom-test
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.geom :as geom]
[arthur.domain.landmarks :as lm]
[arthur.synth :as synth]))
(defn- close? [a b] (< (abs (- a b)) 1e-9))
(deftest fit-similarity-recovers-a-known-transform
(let [src [{:x 0 :y 0} {:x 1 :y 0} {:x 0 :y 1} {:x 2 :y 3}]
truth {:s 1.7 :theta 0.6 :tx 4 :ty -2}
dst (mapv #(geom/apply-sim truth %) src)
got (geom/fit-similarity src dst)]
(is (close? (:s got) (:s truth)) (str "s = " (:s got)))
(is (close? (:theta got) (:theta truth)) (str "theta = " (:theta got)))
(is (close? (:tx got) (:tx truth)) (str "tx = " (:tx got)))
(is (close? (:ty got) (:ty truth)) (str "ty = " (:ty got)))))
(deftest fit-residual-is-zero-on-an-exact-fit
(let [src [{:x 0 :y 0} {:x 1 :y 0} {:x 0 :y 1} {:x 2 :y 3}]
truth {:s 1.7 :theta 0.6 :tx 4 :ty -2}
dst (mapv #(geom/apply-sim truth %) src)]
(is (close? 0 (geom/fit-residual (geom/fit-similarity src dst) src dst)))))
(deftest fit-similarity-survives-a-degenerate-configuration
;; All points coincident: the norm is zero and the scale has nothing to
;; recover. It must fall back to 1 rather than divide by zero, or one bad
;; detection frame poisons the Procrustes mean and therefore every frame.
(let [p (vec (repeat 4 {:x 0.5 :y 0.5}))
tf (geom/fit-similarity p p)]
(is (close? 1 (:s tf)) (str "s = " (:s tf)))
(is (not (js/isNaN (:tx tf))))))
(deftest procrustes-mean-is-the-mean-not-frame-zero
;; The reference is the MEAN rigid configuration over the shot, so no single
;; frame's idiosyncrasies get baked into every other frame. Asserted as a
;; CONTRAST against the design it replaced — a frame-zero reference — because
;; the absolute number alone would not show the invariant has any teeth.
;;
;; Corrupting ONE landmark, not the whole frame: displacing every point of a
;; frame is a pure translation, which fit-similarity removes exactly, so it
;; would prove nothing. And the comparison is a similarity residual rather
;; than a per-point distance, because a Procrustes reference is only defined up
;; to a similarity — the initial reference fixes the gauge, and measuring the
;; gauge instead of the shape is the trap here.
(let [dense (synth/synth-dense 72)
rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) dense)
;; 0.05 is roughly the whole sway amplitude: a gross detection error on
;; one landmark of one frame.
broken (assoc rigid 0 (update-in (nth rigid 0) [3 :x] + 0.05))
;; Gauge-free shape distance: align a onto b, report what is left.
shape-gap (fn [a b] (geom/fit-residual (geom/fit-similarity a b) a b))
mean-gap (shape-gap (geom/procrustes-mean rigid)
(geom/procrustes-mean broken))
;; The design it replaced: frame zero IS the reference, so the same
;; corruption lands in the reference at full strength.
frame0-gap (shape-gap (nth rigid 0) (nth broken 0))]
(is (< mean-gap (* 0.1 frame0-gap))
(str "one corrupt landmark moved the mean reference by " mean-gap
" but a frame-zero reference by " frame0-gap))
(is (> frame0-gap 0.005)
(str "the corruption has to actually register somewhere, or the contrast "
"above is vacuous; frame-zero gap was " frame0-gap))))
(deftest moving-average-radius-semantics
;; 0 is off, 1 averages over 3 frames, 2 over 5. Expressed as a radius rather
;; than a window so that "off" is 0 and every value is symmetric.
(let [v [0 0 9 0 0]]
(is (= v (geom/moving-average v 0)) "radius 0 is identity")
(is (close? 3 (nth (geom/moving-average v 1) 2)) "radius 1 averages over 3")
(is (close? 1.8 (nth (geom/moving-average v 2) 2)) "radius 2 averages over 5"))
(testing "edges clamp rather than shorten the window"
(is (= 5 (count (geom/moving-average [1 2 3 4 5] 2))))
(is (close? 1.6 (first (geom/moving-average [1 2 3 4 5] 2))))))
(deftest smooth-transforms-cannot-spike-at-the-angle-wrap
;; Angles are smoothed as (cos, sin) so wrapping cannot produce a spike.
;; Averaging theta directly across the +/-pi seam gives ~0 - a transform
;; rotated a half turn from both neighbours.
(let [near-pi (- js/Math.PI 0.01)
tfs [{:s 1 :theta near-pi :tx 0 :ty 0}
{:s 1 :theta (- near-pi) :tx 0 :ty 0}
{:s 1 :theta near-pi :tx 0 :ty 0}]
out (geom/smooth-transforms tfs 1)]
(is (every? #(> (abs (:theta %)) 3.0) out)
(str "thetas stayed near the seam: " (pr-str (mapv :theta out))))))
(deftest smooth-transforms-leaves-a-steady-track-alone
(let [tfs (vec (repeat 5 {:s 1.2 :theta 0.3 :tx 4 :ty -2}))]
(is (every? (fn [t] (and (close? 1.2 (:s t)) (close? 0.3 (:theta t))
(close? 4 (:tx t)) (close? -2 (:ty t))))
(geom/smooth-transforms tfs 2)))))
(deftest smoothing-the-transform-buys-less-than-it-costs-on-this-track
;; MEASURED, not assumed, on two independent noise realisations. The synthetic
;; track can be generated WITHOUT jitter, so the real head motion is available
;; as ground truth. error = mean distance of (tx,ty) from the truth; hf = mean
;; |second difference| of tx, the high-frequency energy a low-pass removes.
;;
;; radius error (seeded) error (Math.random) hf
;; 0 6.96e-4 7.36e-4 1.24e-3
;; 1 7.00e-4 6.15e-4 9.19e-4
;; 2 1.49e-3 1.39e-3 8.76e-4
;; 3 2.77e-3 2.66e-3 8.35e-4
;; 5 6.43e-3 6.34e-3 7.67e-4
;; truth 0 0 8.08e-4
;;
;; Read the first two columns together. Radius 1 helps by 17% on one jitter
;; realisation and hurts by 0.5% on another, so its benefit is WITHIN NOISE and
;; asserting it would be asserting a coin flip. From radius 2 up the cost is
;; unambiguous and grows fast. The floor is the last column: the true motion has
;; its own high-frequency content, 8.08e-4, and by radius 5 the filter is below
;; it — buying a smoother number by destroying performance, which is the entire
;; asset.
;;
;; So "smooth the transform" is not a law, and docs/port-plan.md already says as
;; much: it was broken by a bounded exception once keys went dense. On this
;; track the head motion is fast enough and the detector jitter small enough
;; that the low-pass has no useful range at all. That is a fact about THIS
;; synthetic track — real detector noise is larger — so the knob stays, and
;; these assertions pin the shape of it rather than a preferred value.
(let [rigid-of (fn [dense] (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) dense))
;; Both fitted against the SAME reference — the clean track's — so tx and
;; ty are in one gauge. Fitting each against its own Procrustes mean would
;; put them in different frames and the difference would be mostly gauge.
clean (rigid-of (synth/synth-dense 72 {:rand-fn (constantly 0.5)}))
noisy (rigid-of (synth/synth-dense 72))
ref (geom/procrustes-mean clean)
truth (mapv #(geom/fit-similarity % ref) clean)
measured (mapv #(geom/fit-similarity % ref) noisy)
err (fn [tfs]
(/ (reduce + (map (fn [a b] (js/Math.hypot (- (:tx a) (:tx b))
(- (:ty a) (:ty b))))
tfs truth))
(count truth)))
hf (fn [tfs]
(let [tx (mapv :tx tfs) n (count tx)]
(/ (reduce + (map (fn [i] (abs (+ (- (nth tx (inc i))
(* 2 (nth tx i)))
(nth tx (dec i)))))
(range 1 (dec n))))
(- n 2))))
at (fn [r] (geom/smooth-transforms measured r))]
(is (pos? (err measured)) "the jittered track has to differ from the clean one at all")
(testing "radius 1 is a wash — within noise of not smoothing at all"
(is (< (abs (- (err (at 1)) (err measured))) (* 0.2 (err measured)))
(str "radius 0 " (err measured) " vs radius 1 " (err (at 1)))))
(testing "and past that the cost is unambiguous, so more is not better"
(is (> (err (at 5)) (* 5 (err measured)))
(str "radius 0 " (err measured) " vs radius 5 " (err (at 5)))))
(testing "it is a low-pass, so high-frequency energy falls monotonically"
(let [es (mapv #(hf (at %)) [0 1 2 3 5])]
(is (apply >= es) (str "hf energy by radius: " (pr-str es)))))
(testing "and eventually through the floor: the true motion's own hf energy"
(is (and (> (hf (at 2)) (hf truth)) (< (hf (at 5)) (hf truth)))
(str "truth hf " (hf truth) ", radius 2 " (hf (at 2))
", radius 5 " (hf (at 5)))))))

View file

@ -0,0 +1,73 @@
(ns arthur.domain.landmarks-test
"Assertions over the index tables themselves. Every one of these is a contract
some later stage reads off the table without re-deriving it."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.landmarks :as lm]
[clojure.set :as set]))
(deftest table-sizes
(is (= 20 (count (set lm/LIPS-OUTER))) "LIPS-OUTER has 20 distinct ids")
(is (= 20 (count (set lm/LIPS-INNER))) "LIPS-INNER has 20 distinct ids")
(is (= 36 (count (set lm/FACE-OVAL))) "FACE-OVAL has 36 distinct ids")
(is (= 16 (count (set lm/EYE-R-RING))) "eye rings have 16 distinct ids each")
(is (= 16 (count (set lm/EYE-L-RING))))
(is (= 10 (count (set lm/BROW-A-RING))) "brow rings have 10 distinct ids each")
(is (= 10 (count (set lm/BROW-B-RING)))))
(deftest rigid-set-holds-nothing-that-performs
(testing "a moving feature in the rigid set bleeds performance into stabilisation"
(is (empty? (set/intersection (set lm/RIGID)
(into (set lm/LIPS-OUTER) lm/LIPS-INNER)))
"RIGID excludes every lip vertex")
(is (empty? (set/intersection (set lm/RIGID)
(into (set lm/BROW-A-RING) lm/BROW-B-RING)))
"a brow in RIGID would bleed expression into the stabilisation")))
(deftest rings-are-disjoint
(is (empty? (set/intersection (set lm/EYE-R-RING) (set lm/EYE-L-RING)))
"the two eye rings share no landmark")
(is (empty? (set/intersection (set lm/BROW-A-RING) (set lm/BROW-B-RING)))
"the two brow rings share no landmark")
(is (empty? (set/intersection (into (set lm/BROW-A-RING) lm/BROW-B-RING)
(into (set lm/EYE-R-RING) lm/EYE-L-RING)))
"no brow landmark is also a lid landmark"))
(deftest eye-ring-cardinals
;; The cardinal contract, asserted rather than trusted: on a 16-slot ring the
;; quarter slots must be the four anatomical cardinals, which is what makes
;; every even vertex budget land on real landmarks instead of between them.
(is (= [(nth lm/EYE-R-RING 0) (nth lm/EYE-R-RING 4)
(nth lm/EYE-R-RING 8) (nth lm/EYE-R-RING 12)]
[(first lm/EYE-R-CORNERS) (first lm/EYE-R-LIDS)
(second lm/EYE-R-CORNERS) (second lm/EYE-R-LIDS)])
"right eye slot 0/4/8/12 are outer, upper, inner, lower")
(is (= [(nth lm/EYE-L-RING 0) (nth lm/EYE-L-RING 4)
(nth lm/EYE-L-RING 8) (nth lm/EYE-L-RING 12)]
[(first lm/EYE-L-CORNERS) (first lm/EYE-L-LIDS)
(second lm/EYE-L-CORNERS) (second lm/EYE-L-LIDS)])
"left eye slot 0/4/8/12 are outer, upper, inner, lower"))
(deftest eye-corners-are-rigid
;; The gaze origin and denominator are built from the eye corners, so if a
;; corner were not rigid a blink could move it and fake a glance.
(is (every? (set lm/RIGID) (concat lm/EYE-R-CORNERS lm/EYE-L-CORNERS))
"every eye corner is a rigid landmark"))
(deftest aperture-sits-on-the-inner-ring-cardinals
;; The aperture is the inner ring's own height, not a separately written pair.
;; Writing them twice is what produced the bowtie.
(is (= lm/APERTURE [(nth lm/LIPS-INNER 5) (nth lm/LIPS-INNER 15)])
"APERTURE is slots 5 and 15 of LIPS-INNER"))
(deftest brow-end-slots
;; Both ends land on fixed slots whichever edge of the brow is on top, which
;; is what lets the upper/lower ambiguity go unresolved without consequence.
(is (and (= 2 (count lm/BROW-END-0)) (= 2 (count lm/BROW-END-1))
(empty? (set/intersection (set lm/BROW-END-0) (set lm/BROW-END-1))))
"brow end slots are disjoint and cover both ends"))
(deftest iris-blocks
(is (= 5 (count lm/IRIS-A) (count lm/IRIS-B))
"each iris block is a centre plus four ring points")
(is (= 478 lm/NUM-LANDMARKS)
"the refined mesh emits 468 face landmarks plus two iris blocks"))

View file

@ -0,0 +1,133 @@
(ns arthur.domain.raster-test
"The rasteriser's contract is: indexed, hard-edged, no blending. Every
assertion here is about a way that could silently stop being true."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.raster :as r]
[arthur.domain.palette :as pal]))
(defn- count-index [{:keys [buf]} index]
(count (filter #(= index %) (array-seq buf))))
(defn- bbox
"[minx maxx miny maxy count] of the pixels holding `index`, or nil if none."
[{:keys [w h buf]} index]
(let [hits (for [y (range h) x (range w)
:when (= index (aget buf (+ (* y w) x)))]
[x y])]
(when (seq hits)
[(apply min (map first hits)) (apply max (map first hits))
(apply min (map second hits)) (apply max (map second hits))
(count hits)])))
(deftest only-written-indices-appear
(let [ras (-> (r/make 64 48)
(r/clear! 0)
(r/fill-poly! [{:x 8 :y 8} {:x 56 :y 8} {:x 56 :y 40} {:x 8 :y 40}] 2))
present (set (array-seq (:buf ras)))]
(is (= #{0 2} present) (str "indices present: " (pr-str present)))))
(deftest an-axis-aligned-rect-fills-the-exact-pixel-count
;; Off-by-one at the scanline or span boundary is the whole risk here, and it
;; would read as a one-pixel seam between two parts rather than as an error.
(let [ras (-> (r/make 64 48)
(r/clear! 0)
(r/fill-poly! [{:x 8 :y 8} {:x 56 :y 8} {:x 56 :y 40} {:x 8 :y 40}] 2))]
(is (= (* 48 32) (count-index ras 2))
(str (count-index ras 2) " vs " (* 48 32)))))
(deftest a-degenerate-polygon-draws-nothing
(let [ras (-> (r/make 16 16) (r/clear! 0))]
(r/fill-poly! ras [{:x 2 :y 2} {:x 9 :y 2}] 1)
(is (zero? (count-index ras 1)) "two points are not a polygon")
;; A horizontal edge contributes no crossing. If it were not skipped the
;; division by its zero height would emit Infinity and flood the scanline.
(r/fill-poly! ras [{:x 2 :y 2} {:x 9 :y 2} {:x 5 :y 2}] 1)
(is (zero? (count-index ras 1)) "a flat triangle has no interior")))
(deftest palette-expansion-introduces-no-intermediate-colours
;; Antialiasing anywhere in the chain would show up here as a colour that is in
;; neither palette entry, and it is exactly the thing the preview exists to not
;; do.
(let [ras (-> (r/make 8 8) (r/clear! 0))
_ (r/fill-poly! ras [{:x 1 :y 1} {:x 7 :y 1} {:x 7 :y 7} {:x 1 :y 7}] 4)
{:keys [data]} (r/->rgba ras pal/rgb 2)
seen (set (for [i (range 0 (alength data) 4)]
[(aget data i) (aget data (+ i 1)) (aget data (+ i 2))]))]
(is (every? (set pal/rgb) seen)
(str "colours not in the palette: " (pr-str (remove (set pal/rgb) seen))))
(is (= 2 (count seen)) (str (count seen) " distinct colours, expected 2"))))
(deftest rgba-is-opaque-and-nearest-neighbour
(let [ras (-> (r/make 4 4) (r/clear! 1))
{:keys [width height data]} (r/->rgba ras pal/rgb 3)]
(is (= [12 12] [width height]))
(is (every? #(= 255 (aget data %)) (range 3 (alength data) 4)) "every alpha is 255")
(is (= (* 12 12 4) (alength data)))))
(deftest an-index-with-no-palette-entry-is-loudly-wrong
;; Magenta rather than black or transparent: writing an index the palette does
;; not have is a bug, and it should be impossible to miss.
(let [ras (-> (r/make 2 2) (r/clear! 200))
{:keys [data]} (r/->rgba ras pal/rgb)]
(is (= [255 0 255] [(aget data 0) (aget data 1) (aget data 2)]))))
;; ---- the stencil, which is what keeps the iris inside the eye ----
(deftest a-stencilled-disc-cannot-spill-past-its-clip
(let [ras (-> (r/make 40 40) (r/clear! 0))]
(r/fill-poly! ras [{:x 10 :y 10} {:x 30 :y 10} {:x 30 :y 20} {:x 10 :y 20}] 1)
(r/fill-disc! ras 28 15 9 2 1) ; a disc reaching well past the "lid"
(let [in-lid? (fn [x y] (and (>= x 10) (< x 30) (>= y 10) (< y 20)))
hits (for [y (range 40) x (range 40)
:when (= 2 (aget (:buf ras) (+ (* y 40) x)))]
[x y])]
(is (every? (fn [[x y]] (in-lid? x y)) hits)
(str (count (remove (fn [[x y]] (in-lid? x y)) hits)) " pixels spilled"))
(is (> (count hits) 20) "and the disc actually drew something"))))
(deftest an-unstencilled-disc-still-writes-anywhere
(let [ras (-> (r/make 40 40) (r/clear! 0))]
(r/fill-disc! ras 5 35 3 3)
(is (pos? (count-index ras 3)))))
(deftest the-pupil-inherits-the-iris-clip-transitively
;; The stencil chain: pupil over iris over sclera. A pupil placed where the
;; iris has already been cropped must be cropped the same way.
(let [ras (-> (r/make 40 40) (r/clear! 0))]
(r/fill-poly! ras [{:x 10 :y 10} {:x 30 :y 10} {:x 30 :y 20} {:x 10 :y 20}] 1)
(r/fill-disc! ras 28 15 9 2 1)
(r/fill-rect! ras 28 15 5 4 2)
(let [spill (for [y (range 40) x (range 40)
:when (and (= 4 (aget (:buf ras) (+ (* y 40) x)))
(not (and (>= x 10) (< x 30) (>= y 10) (< y 20))))]
[x y])]
(is (empty? spill) (str "pupil spilled at " (pr-str (vec spill))))
(is (pos? (count-index ras 4)) "and the pupil actually drew something"))))
;; ---- the pupil is the same mark on every frame ----
(deftest a-three-pixel-pupil-is-three-by-three-at-every-centre
;; A square pupil is only worth having if it is the SAME square every frame:
;; exactly its nominal size at any centre, or it breathes as the gaze moves.
(doseq [[cx cy] [[20 20] [20.5 20.5] [20.49 19.51] [21 20] [20.9 20.1]]]
(let [ras (-> (r/make 40 40) (r/clear! 0))]
(r/fill-rect! ras cx cy 3 1)
(let [[x0 x1 y0 y1 n] (bbox ras 1)]
(is (= [3 3 9] [(inc (- x1 x0)) (inc (- y1 y0)) n])
(str "centre " cx "," cy " gave " (inc (- x1 x0)) "x" (inc (- y1 y0)) ":" n))))))
(deftest pupil-size-zero-draws-nothing
(let [ras (-> (r/make 40 40) (r/clear! 0))]
(r/fill-rect! ras 20 20 0 1)
(is (zero? (count-index ras 1)))))
;; ---- the palette itself ----
(deftest palette-indices-are-stable-and-derived
(is (= 0 (:bg pal/index-of)) "bg is index 0, which clear! relies on")
(is (= (count pal/entries) (count pal/index-of) (count pal/rgb)))
(is (apply distinct? (map :name pal/entries)) "no duplicate palette names")
(is (every? #(= 3 (count %)) pal/rgb))
(is (= [18 20 28] (pal/hex->rgb "#12141c")))
(testing "index-of round-trips against the ordered vector"
(is (every? (fn [[k i]] (= k (:name (nth pal/entries i)))) pal/index-of))))

View file

@ -0,0 +1,107 @@
(ns arthur.domain.ring-test
"The ring-simplicity check exists because \"fixed topology\" is load-bearing in
docs/design.md: because hold parts CUT between poses rather than
interpolating, a ring whose vertex order is wrong self-intersects and renders
as blocks meeting at corners. It is invisible at some vertex counts and obvious
at others, so it needs an assertion rather than an eyeball."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.landmarks :as lm]
[arthur.domain.ring :as ring]
[arthur.synth :as synth]))
(def dense (delay (synth/synth-dense 72)))
(defn- first-bad-budget
"The first [budget frame edge] at which `table`'s subsampled ring
self-intersects on the synthetic track, or nil. Reported rather than asserted
per-frame so a failure names the case instead of only the count."
[table budgets]
(first
(for [n budgets
:let [slots (ring/subsample-slots (count table) n)]
[f fr] (map-indexed vector @dense)
:let [pts (mapv #(nth fr (nth table %)) slots)
hits (ring/self-intersections pts)]
:when (seq hits)]
{:verts n :frame f :edges (first hits)})))
(deftest subsample-preserves-order-and-count
(doseq [n (range 4 17 2)]
(let [s (ring/subsample-slots 20 n)]
(is (= n (count s)) (str "subsample-slots(20," n ") returns n slots"))
(is (apply < s) (str "subsample-slots(20," n ") is strictly increasing: " (pr-str s))))))
(deftest subsample-ring-agrees-with-subsample-slots
(is (= (ring/subsample-ring lm/LIPS-OUTER 8)
(mapv #(nth lm/LIPS-OUTER %) (ring/subsample-slots 20 8)))))
(deftest lid-ring-carries-its-own-corners
;; Subsampling a 16-slot ring to any even budget must keep the two corners at
;; output indices 0 and n/2. That is what lets the socket be read back off the
;; drawn polygon instead of measured separately, which is what stops the iris
;; drifting relative to the eye it sits in.
(doseq [n (range 4 13 2)]
(let [sl (ring/subsample-slots 16 n)]
(is (and (= 0 (nth sl 0)) (= 8 (nth sl (quot n 2))))
(str "n=" n " -> " (pr-str sl))))))
(deftest lip-rings-are-simple-at-every-vertex-budget
(doseq [[label table] [["outer" lm/LIPS-OUTER] ["inner" lm/LIPS-INNER]]]
(is (nil? (first-bad-budget table (range 4 17 2)))
(str label " ring self-intersects: "
(pr-str (first-bad-budget table (range 4 17 2)))))))
(deftest face-oval-is-a-simple-ring-on-every-frame
;; A wrong ordering here shows up as a lumpy plate rather than an obvious
;; bowtie, so it needs asserting.
(is (nil? (first-bad-budget lm/FACE-OVAL [(count lm/FACE-OVAL)]))))
(deftest eye-rings-are-simple-at-every-vertex-budget
;; Checked on blink frames too, where the ring is nearly degenerate.
(doseq [[label table] [["right" lm/EYE-R-RING] ["left" lm/EYE-L-RING]]]
(is (nil? (first-bad-budget table (range 4 13 2)))
(str label " eye ring self-intersects: "
(pr-str (first-bad-budget table (range 4 13 2)))))))
(deftest brow-rings-are-simple-at-every-vertex-budget
(doseq [[label table] [["A" lm/BROW-A-RING] ["B" lm/BROW-B-RING]]]
(is (nil? (first-bad-budget table (range 4 11 2)))
(str "brow " label " ring self-intersects: "
(pr-str (first-bad-budget table (range 4 11 2)))))))
(deftest offset-ring-grows-by-a-fixed-amount
;; offset-ring must grow by a FIXED amount and survive a degenerate ring - the
;; shut eyelid is exactly the degenerate case, and it is the frame where the
;; lash line is the entire drawing.
(let [sq [{:x -1 :y 0} {:x 0 :y -1} {:x 1 :y 0} {:x 0 :y 1}]
g (ring/offset-ring sq 2)]
(is (every? true?
(map (fn [p q] (< (abs (- (js/Math.hypot (:x p) (:y p))
(+ (js/Math.hypot (:x q) (:y q)) 2)))
1e-9))
g sq))
"offset-ring pushes every vertex out by exactly d")
(is (identical? sq (ring/offset-ring sq 0)) "offset-ring(0) is identity")))
(deftest offset-ring-survives-a-shut-lid
(let [shut-lid [{:x -10 :y 0} {:x 0 :y -0.02} {:x 10 :y 0} {:x 0 :y 0.02}]
band (ring/offset-ring shut-lid 1.5)
h (- (apply max (map :y band)) (apply min (map :y band)))]
(is (> h 2.9) (str "shut lid gets a visible lash band, height " h))
(is (ring/simple? band) "offset-ring keeps the shut lid simple")))
(deftest offset-ring-tolerates-a-vertex-on-the-centroid
;; A vertex sitting exactly on the centroid has no outward direction. Leave it
;; where it is rather than emitting NaN and poisoning the whole ring.
(let [degenerate [{:x 0 :y 0} {:x 0 :y 0} {:x 0 :y 0}]]
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %))))
(ring/offset-ring degenerate 3)))))
(deftest a-bowtie-is-detected
;; The detector has to actually fire, or every simplicity assertion above is
;; asserting nothing. Swapping two opposite vertices of a square is exactly
;; the bowtie the lip rings grew.
(let [square [{:x 0 :y 0} {:x 10 :y 0} {:x 10 :y 10} {:x 0 :y 10}]
bowtie [{:x 0 :y 0} {:x 10 :y 0} {:x 0 :y 10} {:x 10 :y 10}]]
(is (ring/simple? square))
(is (not (ring/simple? bowtie)) "a swapped pair of opposite vertices is caught")))

View file

@ -0,0 +1,200 @@
(ns arthur.parity-test
"Diffs the CLJS port against js/ — the numeric oracle — on the identical
synthetic track.
DELETABLE, and deliberately so. This namespace and test/parity/oracle.mjs go
together, in one commit, once the CLJS player renders the synthetic take
correctly (port-plan step 5).
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 the code moves, and a correctness test asserts something
that has been decided and stays. Conflating the two bakes the prototype's
mistakes into the rewrite and makes them permanent — so nothing in here is
allowed to outlive the move, and nothing in here is evidence that a number is
the number we want.
Run test/parity/oracle.mjs first; `npm test` does."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.geom :as geom]
[arthur.domain.landmarks :as lm]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.ring :as ring]
[arthur.synth :as synth]))
;; The port plan's number. A larger gap than this is a port bug, not float noise.
(def TOL 1e-9)
(def oracle
(delay
(let [fs (js/require "fs")
path (str js/__dirname "/../test/parity/oracle.json")]
(when-not (.existsSync fs path)
(throw (ex-info (str "no oracle at " path
" — run `node test/parity/oracle.mjs` first")
{:path path})))
(js->clj (js/JSON.parse (.readFileSync fs path "utf8")) :keywordize-keys true))))
;; Both sides get jitter of exactly zero, which is what makes the tracks
;; comparable at all: the JS uses Math.random and the CLJS a seeded generator.
(def track (delay (synth/synth-dense (:frames @oracle) {:rand-fn (constantly 0.5)})))
(defn- worst
"The largest absolute difference between two equally-shaped nested numeric
structures, and where it was, so a failure names the case."
[a b]
(let [seen (atom {:d -1 :at nil})]
(letfn [(walk [x y path]
(cond
(number? x)
(let [d (abs (- x y))]
(when (> d (:d @seen)) (reset! seen {:d d :at path :got x :want y})))
(map? x)
(doseq [k (keys x)] (walk (get x k) (get y k) (conj path k)))
(sequential? x)
(do (when (not= (count x) (count y))
(throw (ex-info "shape mismatch" {:at path :got (count x) :want (count y)})))
(dotimes [i (count x)] (walk (nth x i) (nth y i) (conj path i))))
:else nil))]
(walk a b []))
@seen))
(defn- agrees?
"Assert two structures agree to TOL, reporting the worst offender."
[label a b]
(let [{:keys [d at got want]} (worst a b)]
(is (< d TOL)
(str label ": worst gap " d " at " (pr-str at) " (" got " vs " want ")"))))
;; ---- the track itself ----
;;
;; Everything below is meaningless if the two synths disagree, so this is
;; asserted first and separately: a track mismatch would otherwise surface as a
;; dozen numeric failures pointing nowhere near the cause.
(deftest the-two-synths-produce-the-same-track
(let [js-track (:track @oracle)]
(is (= (count js-track) (count @track)))
(is (= (count (first js-track)) (count (first @track))))
(agrees? "synthetic track" @track js-track)))
;; ---- the tables ----
(deftest tables-were-transcribed-without-a-typo
(let [t (:tables @oracle)]
(is (= lm/RIGID (:RIGID t)))
(is (= lm/LIPS-OUTER (:LIPS_OUTER t)))
(is (= lm/EYE-R-RING (:EYE_R_RING t)))
(is (= lm/BROW-A-RING (:BROW_A_RING t)))
(is (= lm/FACE-OVAL (:FACE_OVAL t)))))
;; ---- domain/ring ----
(deftest subsample-slots-agrees
;; The oracle keys are "len/n", which js->clj reads as a NAMESPACED keyword —
;; so the length is the namespace, not the first half of the name.
(doseq [[k want] (:subsampleSlots @oracle)]
(let [len (js/parseInt (namespace k))
n (js/parseInt (name k))]
(is (= want (ring/subsample-slots len n))
(str "subsample-slots(" len "," n ")")))))
(deftest offset-ring-agrees
(doseq [{:keys [d ring shutLid collapsed]} (:offsetRing @oracle)]
(let [src (mapv #(nth (first @track) %) lm/LIPS-OUTER)]
(agrees? (str "offset-ring(lips, " d ")")
(mapv #(select-keys % [:x :y]) (ring/offset-ring src d))
(mapv #(select-keys % [:x :y]) ring)))
(agrees? (str "offset-ring(shut lid, " d ")")
(mapv #(select-keys % [:x :y])
(ring/offset-ring [{:x -10 :y 0} {:x 0 :y -0.02}
{:x 10 :y 0} {:x 0 :y 0.02}] d))
(mapv #(select-keys % [:x :y]) shutLid))
;; A vertex on the centroid has no outward direction. Both sides must leave
;; it alone rather than emit NaN, and NaN != NaN would slip past `worst`.
(let [got (ring/offset-ring [{:x 0 :y 0} {:x 0 :y 0} {:x 0 :y 0}] d)]
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %)))) got)
(str "offset-ring(collapsed, " d ") is NaN-free"))
(is (every? #(and (not (js/isNaN (:x %))) (not (js/isNaN (:y %)))) collapsed)
"...and the oracle's is too, so this is parity and not a shared bug"))))
;; ---- domain/geom: the two the port plan names ----
(deftest fit-similarity-agrees-on-a-known-transform
(let [{:keys [src dst fit]} (:known @oracle)]
(agrees? "fit-similarity (known transform)"
(geom/fit-similarity src dst)
fit)))
(deftest fit-similarity-agrees-over-the-whole-shot
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)
ref (:rigidRef @oracle)]
;; Fitted against the ORACLE's reference, so this isolates fit-similarity
;; from procrustes-mean instead of compounding the two.
(agrees? "fit-similarity over 72 frames"
(mapv #(geom/fit-similarity % ref) rigid)
(:transforms @oracle))))
(deftest procrustes-mean-agrees
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)]
(agrees? "procrustes-mean"
(mapv #(select-keys % [:x :y]) (geom/procrustes-mean rigid))
(mapv #(select-keys % [:x :y]) (:rigidRef @oracle)))))
(deftest fit-residual-agrees
(let [rigid (mapv (fn [fr] (mapv #(nth fr %) lm/RIGID)) @track)
ref (:rigidRef @oracle)]
(agrees? "fit-residual"
(mapv (fn [r tf] (geom/fit-residual tf r ref))
rigid (:transforms @oracle))
(:residuals @oracle))))
;; ---- domain/geom: the smoothing knobs ----
(deftest moving-average-agrees-at-every-radius
(let [tx (mapv :tx (:transforms @oracle))]
(doseq [{:keys [radius vals]} (:movingAverage @oracle)]
(agrees? (str "moving-average radius " radius)
(geom/moving-average tx radius)
vals))))
(deftest smooth-transforms-agrees-at-every-radius
(doseq [{:keys [radius tfs]} (:smoothed @oracle)]
(agrees? (str "smooth-transforms radius " radius)
(geom/smooth-transforms (:transforms @oracle) radius)
tfs)))
;; ---- domain/raster ----
(deftest raster-agrees-pixel-for-pixel
;; Integer output, so this is EXACT equality and not TOL. The whole buffer is
;; diffed rather than a pixel count: one scanline a pixel wide of the JS would
;; read as a seam between two parts, not as an error, and a count would miss it.
(let [{:keys [w h buf]} (:raster @oracle)
fr (first @track)
ras (-> (raster/make w h) (raster/clear! 0))]
(raster/fill-poly! ras (mapv (fn [i] {:x (- (* (:x (nth fr i)) 320) 100)
:y (- (* (:y (nth fr i)) 200) 40)})
lm/LIPS-OUTER) 2)
(raster/fill-poly! ras [{:x 8.5 :y 8.5} {:x 56.25 :y 8.5}
{:x 56.25 :y 40.75} {:x 8.5 :y 40.75}] 1)
(raster/fill-disc! ras 30.4 24.6 9.2 3 1)
(raster/fill-disc! ras 5.5 44.5 4 4)
(raster/fill-rect! ras 30.49 24.51 3 5 3)
(raster/fill-rect! ras 1 1 0 6)
(let [got (vec (array-seq (:buf ras)))
diff (keep-indexed (fn [i v] (when (not= v (nth buf i))
{:at [(mod i w) (quot i w)]
:got v :want (nth buf i)}))
got)]
(is (empty? diff)
(str (count diff) " of " (* w h) " pixels differ, first few: "
(pr-str (vec (take 5 diff)))))
;; A buffer that agreed because both sides drew nothing would pass the
;; above, so check the drawing actually happened.
(is (> (count (distinct got)) 3)
(str "only " (pr-str (distinct got)) " indices present")))))
(deftest hex-to-rgb-agrees
(agrees? "palette rgb" pal/rgb (:paletteRgb @oracle)))

View file

@ -0,0 +1,173 @@
(ns arthur.synth
"Synthetic landmark frames, shaped exactly like FaceLandmarker output.
Exists so the whole chain downstream of detection - Procrustes, smoothing,
stabilisation, key selection, rasterising, take writing - can be exercised and
verified without a video file. A synthetic face is also the only way to test
stabilisation against a KNOWN head motion, since real footage gives no ground
truth to compare against.
Differs from js/synth.js in exactly one way, deliberately: the jitter comes
from a SEEDED generator rather than Math.random. Two reasons. A failing
assertion has to be reproducible to be worth anything, and the JS is the
numeric oracle - parity is only checkable if both sides can be handed the same
track. `:rand-fn` takes the generator over, so stubbing js/Math.random in a
node harness makes the two implementations agree exactly."
(:require [arthur.domain.landmarks :as lm]))
;; mulberry32. Chosen for being four lines of int32 arithmetic that port
;; unambiguously between JS and CLJS, not for its statistics: this is jitter for
;; a smoother to remove, not a source of entropy.
(defn mulberry32 [seed]
(let [a (atom (bit-or seed 0))]
(fn []
(let [x (swap! a (fn [v] (bit-or (+ v 0x6D2B79F5) 0)))
t (js/Math.imul (bit-xor x (unsigned-bit-shift-right x 15)) (bit-or 1 x))
t (bit-xor (+ t (js/Math.imul (bit-xor t (unsigned-bit-shift-right t 7))
(bit-or 61 t)))
t)]
(/ (unsigned-bit-shift-right (bit-xor t (unsigned-bit-shift-right t 14)) 0)
4294967296)))))
;; Half the corner separation, and the lid half-height at full open.
(def ^:private EYE-RX 0.0235)
(def ^:private EYE-RY 0.011)
(def ^:private EYE-Y -0.044)
(defn synth-dense
"`n-frames` of dense landmarks.
`:swap-iris` places the two iris blocks on the opposite eyes. It exists so the
pairing resolver can be tested against a track it actually disagrees with:
a resolver checked only against the convention it was written for is checking
nothing at all."
([] (synth-dense 72 {}))
([n-frames] (synth-dense n-frames {}))
([n-frames {:keys [swap-iris rand-fn seed]
:or {swap-iris false, seed 1}}]
(let [rnd (or rand-fn (mulberry32 seed))]
(vec
(for [t (range n-frames)]
(let [pts (make-array lm/NUM-LANDMARKS)
_ (dotimes [i lm/NUM-LANDMARKS] (aset pts i {:x 0.5 :y 0.5 :z 0}))
;; Known head motion: drift, sway, roll and a slow scale change, plus a
;; little per-frame jitter so transform smoothing has something to remove.
ph (/ t n-frames)
hx (+ 0.5 (* 0.045 (js/Math.sin (* ph js/Math.PI 2))) (* (- (rnd) 0.5) 0.002))
hy (+ 0.5 (* 0.02 (js/Math.cos (* ph js/Math.PI 3))) (* (- (rnd) 0.5) 0.002))
roll (* 0.18 (js/Math.sin (* ph js/Math.PI 2.5)))
scale (+ 1 (* 0.06 (js/Math.sin (* ph js/Math.PI 1.5))))
cr (js/Math.cos roll)
sr (js/Math.sin roll)
place (fn [i lx ly]
(let [sx (* lx scale) sy (* ly scale)]
(aset pts i {:x (- (+ hx (* cr sx)) (* sr sy))
:y (+ hy (* sr sx) (* cr sy))
:z 0})))
;; Mouth opens in four sustained beats with holds between, so key selection
;; has genuine extremes and genuine plateaux to find.
beat (mod (js/Math.floor (/ t 9)) 4)
open-amt (nth [0.004 0.05 0.022 0.0] beat)
wide (+ 0.10 (case beat 1 0.012, 3 -0.008, 0))
;; A blink is ONE frame, which is the honest hard case: at 12fps that is
;; what a real blink costs, and it is exactly the length that reads as a
;; dropped frame rather than as a blink unless `hold` extends it.
blink (and (> t 5) (zero? (mod t 19)))
openness (if blink 0.05 1)
;; Gaze holds and then jumps, the way gaze actually behaves, with a little
;; jitter on top so quantisation has noise to remove and the dwell has
;; something to suppress.
[gx gy] (nth [[0 0] [0.16 0.0] [-0.16 0.05] [0.0 -0.09]]
(mod (js/Math.floor (/ t 11)) 4))
jit (fn [] (* (- (rnd) 0.5) 0.012))
;; Eyes. The corners (RIGID[0..3]) are placed BY the lid rings rather than
;; separately, because they are slots 0 and 8 of those rings: writing them
;; twice is how the mouth grew a bowtie, and a corner that disagrees with
;; its own ring would make the eye self-intersect at some vertex budgets
;; and not others.
eye (fn [ring cx dir iris]
(let [n (count ring)]
(dotimes [k n]
;; dir flips the traversal so each ring runs the direction its real
;; table does: slot 0 outer corner, 4 upper lid, 8 inner, 12 lower.
(let [a (if (pos? dir)
(+ js/Math.PI (* (/ k n) js/Math.PI 2))
(- (* (/ k n) js/Math.PI 2)))]
(place (nth ring k)
(+ cx (* EYE-RX (js/Math.cos a)))
(+ EYE-Y (* EYE-RY openness (js/Math.sin a))))))
;; Iris: centre first, then four ring points, as the refined mesh emits.
(let [ix (+ cx (* (+ gx (jit)) EYE-RX 2))
iy (+ EYE-Y (* (+ gy (jit)) EYE-RX 2))
m (count iris)]
(place (nth iris 0) ix iy)
(doseq [k (range 1 m)]
(let [a (* (/ (dec k) (dec m)) js/Math.PI 2)]
(place (nth iris k)
(+ ix (* 0.008 (js/Math.cos a)))
(+ iy (* 0.008 (js/Math.sin a)))))))))
;; Brows, held in four sustained poses so raise quantisation has genuine
;; plateaux to find: rest, surprise (both ends up), worry (inner up only),
;; anger (inner down). Commanded in eye widths above the eye centre so the
;; measurement can be checked against a number rather than an eyeball.
[b-out b-in] (nth [[0.30 0.30] [0.46 0.46] [0.30 0.44] [0.30 0.18]]
(mod (js/Math.floor (/ t 13)) 4))
EYE-W (* EYE-RX 2)
HALF 0.006 ; ring half-thickness
brow (fn [ring cx outer-sign]
;; Slots 0-4 are one edge outer->inner, 5-9 the other inner->outer, so the
;; ends land on {0,9} and {4,5} exactly as the table promises.
(let [n (count ring) half (/ n 2)]
(dotimes [k n]
(let [along (if (< k half)
(/ k (dec half))
(/ (- n 1 k) (dec half)))
rise (+ b-out (* (- b-in b-out) along))]
(place (nth ring k)
(+ cx (* outer-sign (- EYE-RX (* along EYE-W)) 1.05))
(+ (- EYE-Y (* rise EYE-W))
(if (< k half) (- HALF) HALF)))))))
;; Lip rings as ellipse arcs, traversed so ring ORDER matches the tables:
;; slot 0 = right corner, 5 = top centre, 10 = left corner, 15 = bottom
;; centre, with y growing downward. Getting this convention wrong swaps two
;; opposite vertices and the ring self-intersects into a bowtie - see the
;; ring-simplicity assertion in domain/ring's tests.
ring (fn [table rx ry cy]
(let [n (count table)]
(dotimes [k n]
(let [a (- (* (/ k n) js/Math.PI 2))]
(place (nth table k)
(* rx (js/Math.cos a))
(+ cy (* ry (js/Math.sin a))))))))]
(place (nth lm/RIGID 4) 0.000 -0.050)
(place (nth lm/RIGID 5) 0.000 -0.020)
(place (nth lm/RIGID 6) 0.000 0.012)
(eye lm/EYE-R-RING -0.0515 1 (if swap-iris lm/IRIS-B lm/IRIS-A))
(eye lm/EYE-L-RING 0.0515 -1 (if swap-iris lm/IRIS-A lm/IRIS-B))
(brow lm/BROW-A-RING -0.0515 -1)
(brow lm/BROW-B-RING 0.0515 1)
(ring lm/LIPS-OUTER (/ wide 2) (+ 0.012 (* open-amt 0.6)) 0.075)
;; APERTURE (13, 14) are slots 5 and 15 of the inner ring, so the ring itself
;; places them at the vertical extremes. Writing them again afterwards is what
;; produced the bowtie; the aperture is simply the inner ring's height.
(ring lm/LIPS-INNER (/ wide 2.6) (+ 0.001 open-amt) 0.075)
(let [n (count lm/FACE-OVAL)]
(dotimes [k n]
(let [a (+ (- (/ js/Math.PI 2)) (* (/ k n) js/Math.PI 2))]
(place (nth lm/FACE-OVAL k)
(* 0.105 (js/Math.cos a))
(+ (* 0.145 (js/Math.sin a)) 0.01)))))
(vec pts)))))))

View file

@ -0,0 +1,69 @@
(ns arthur.synth-test
"The generator is test infrastructure, so it gets its own assertions: a
silently wrong synthetic track would make every stage downstream of it agree
about the wrong answer."
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.landmarks :as lm]
[arthur.synth :as synth]))
(deftest shape-matches-facelandmarker-output
(let [dense (synth/synth-dense 12)]
(is (= 12 (count dense)))
(is (every? #(= lm/NUM-LANDMARKS (count %)) dense))
(is (every? (fn [fr] (every? #(and (number? (:x %)) (number? (:y %)) (number? (:z %))) fr))
dense))))
(deftest a-seeded-track-is-reproducible
;; A failing assertion has to be reproducible to be worth anything, and the JS
;; is the numeric oracle - parity is only checkable if both sides can be handed
;; the same track.
(is (= (synth/synth-dense 8) (synth/synth-dense 8)))
(is (not= (synth/synth-dense 8 {:seed 1}) (synth/synth-dense 8 {:seed 2}))
"a different seed is a different track, so the jitter is really jittering"))
(deftest rand-fn-takes-the-generator-over
;; This is the hook the JS-parity harness uses: stub js/Math.random on one side
;; and pass the matching constant here, and the two tracks are identical.
(let [zero-jitter (synth/synth-dense 8 {:rand-fn (constantly 0.5)})]
(is (= zero-jitter (synth/synth-dense 8 {:rand-fn (constantly 0.5)})))
(is (not= zero-jitter (synth/synth-dense 8)))))
(deftest the-blink-is-one-frame-every-nineteen
;; At 12fps that is what a real blink costs, and it is exactly the length that
;; reads as a dropped frame rather than as a blink unless `hold` extends it.
(let [dense (synth/synth-dense 72)
lid-gap (fn [fr]
(abs (- (:y (nth fr (first lm/EYE-R-LIDS)))
(:y (nth fr (second lm/EYE-R-LIDS))))))
gaps (mapv lid-gap dense)
open (apply max gaps)
shut (keep-indexed (fn [f g] (when (< g (* 0.2 open)) f)) gaps)]
(is (= [19 38 57] (vec shut))
(str "shut frames were " (pr-str (vec shut))))))
(deftest swap-iris-really-swaps
;; The flag exists so the pairing resolver can be tested against a track it
;; actually disagrees with. If it were a no-op that test would be vacuous.
(let [a (first (synth/synth-dense 4))
b (first (synth/synth-dense 4 {:swap-iris true}))
centre (fn [fr block] (nth fr (first block)))]
(is (= (centre a lm/IRIS-A) (centre b lm/IRIS-B)))
(is (= (centre a lm/IRIS-B) (centre b lm/IRIS-A)))
(is (not= (centre a lm/IRIS-A) (centre b lm/IRIS-A))
"the two iris blocks are on opposite sides of the face, so a swap moves them")))
(deftest the-head-really-moves
;; Stabilisation is tested against a KNOWN head motion, so the motion has to be
;; there. Measured on a rigid landmark, which only the head moves.
(let [dense (synth/synth-dense 72)
xs (map #(:x (nth % (nth lm/RIGID 4))) dense)]
(is (> (- (apply max xs) (apply min xs)) 0.05)
"the nose bridge travels across the frame")))
(deftest the-mouth-really-opens
(let [dense (synth/synth-dense 72)
ap (map (fn [fr] (abs (- (:y (nth fr (first lm/APERTURE)))
(:y (nth fr (second lm/APERTURE))))))
dense)]
(is (> (- (apply max ap) (apply min ap)) 0.05)
"the aperture has genuine extremes for key selection to find")))

4
frontend/test/parity/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Generated by oracle.mjs on every `npm test`. Not committed: it is 1.2MB of
# derived numbers, and a stale copy would make the parity suite pass against
# yesterday's oracle.
oracle.json

View file

@ -0,0 +1,107 @@
// Runs the JS prototype — the numeric oracle — and writes its answers to JSON
// for arthur.parity-test to diff against the CLJS port.
//
// DELETABLE. This file and arthur.parity-test go together, in one commit, once
// the CLJS player renders the synthetic take correctly (port-plan step 5). A
// parity test pins behaviour while the code moves; keeping it afterwards would
// bake the prototype's mistakes into the rewrite and make them permanent.
//
// Math.random is stubbed to a constant so both sides get the IDENTICAL track:
// js/synth.js reads Math.random at call time, not at import time, so assigning
// it here — before synthDense is called below — is enough, and js/ stays
// untouched. 0.5 makes every (Math.random() - 0.5) jitter term exactly zero,
// which is also what `:rand-fn (constantly 0.5)` does on the CLJS side.
Math.random = () => 0.5;
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { RIGID, LIPS_OUTER, EYE_R_RING, BROW_A_RING, FACE_OVAL,
subsampleSlots } from '../../../js/landmarks.js';
import { fitSimilarity, applySim, fitResidual, procrustesMean,
movingAverage, smoothTransforms, offsetRing } from '../../../js/mathutil.js';
import { synthDense } from '../../../js/synth.js';
import { IndexedRaster, hexToRgb } from '../../../js/raster.js';
const FRAMES = 72;
const track = synthDense(FRAMES);
const rigid = track.map((f) => RIGID.map((i) => f[i]));
// The two the port plan names explicitly, plus everything else in mathutil.js:
// a function nobody diffed is a function nobody ported.
const ref = procrustesMean(rigid);
const tfs = rigid.map((r) => fitSimilarity(r, ref));
const strip = (p) => ({ x: p.x, y: p.y, z: p.z ?? 0 });
const stripTf = (t) => ({ s: t.s, theta: t.theta, tx: t.tx, ty: t.ty });
// A known transform recovered exactly, which is the same case the CLJS unit test
// asserts — here so a disagreement can be localised to the fit rather than to
// the track.
const knownSrc = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 2, y: 3 }];
const knownTruth = { s: 1.7, theta: 0.6, tx: 4, ty: -2 };
const knownDst = knownSrc.map((p) => applySim(knownTruth, p));
const out = {
frames: FRAMES,
track: track.map((f) => f.map(strip)),
rigidRef: ref.map(strip),
transforms: tfs.map(stripTf),
residuals: rigid.map((r, i) => fitResidual(tfs[i], r, ref)),
smoothed: [0, 1, 2, 5].map((radius) => ({
radius, tfs: smoothTransforms(tfs, radius).map(stripTf),
})),
known: { src: knownSrc, truth: knownTruth, dst: knownDst,
fit: stripTf(fitSimilarity(knownSrc, knownDst)) },
// tx over the shot is the sway; it is the one-dimensional series the smoothing
// knob actually acts on, so it is what movingAverage gets diffed on.
movingAverage: [0, 1, 2, 3, 7].map((radius) => ({
radius, vals: movingAverage(tfs.map((t) => t.tx), radius),
})),
offsetRing: [0, 0.5, 2, -1].map((d) => ({
d,
ring: offsetRing(LIPS_OUTER.map((i) => track[0][i]), d).map(strip),
// The degenerate case: a shut lid is a flat sliver and must still open into
// a band, and a ring collapsed onto its own centroid must not emit NaN.
shutLid: offsetRing([{ x: -10, y: 0 }, { x: 0, y: -0.02 },
{ x: 10, y: 0 }, { x: 0, y: 0.02 }], d).map(strip),
collapsed: offsetRing([{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }], d).map(strip),
})),
subsampleSlots: Object.fromEntries(
[[20, 4], [20, 6], [20, 8], [20, 10], [20, 16], [16, 4], [16, 6], [16, 12],
[10, 4], [10, 6], [10, 10], [36, 8]]
.map(([len, n]) => [`${len}/${n}`, subsampleSlots(len, n)])),
tables: { RIGID, LIPS_OUTER, EYE_R_RING, BROW_A_RING, FACE_OVAL },
// The raster is integer output, so parity here is EXACT equality, not 1e-9.
// One scanline drawn one pixel wide of the JS would read as a seam between two
// parts rather than as an error, which is why the whole buffer is diffed and
// not a pixel count.
//
// toImageData is not exercised: it needs an ImageData, the CLJS side returns
// plain bytes on purpose so domain/ stays DOM-free, and the palette expansion
// is asserted directly in arthur.domain.raster-test instead.
raster: (() => {
const r = new IndexedRaster(64, 48);
r.clear(0);
// A real mouth ring at raster scale, so the scanline fill is diffed on a
// shape with fractional coordinates and non-convex spans rather than on an
// axis-aligned box that would agree even if the rounding were wrong.
r.fillPoly(LIPS_OUTER.map((i) => ({ x: track[0][i].x * 320 - 100,
y: track[0][i].y * 200 - 40 })), 2);
r.fillPoly([{ x: 8.5, y: 8.5 }, { x: 56.25, y: 8.5 },
{ x: 56.25, y: 40.75 }, { x: 8.5, y: 40.75 }], 1);
r.fillDisc(30.4, 24.6, 9.2, 3, 1); // stencilled by the box
r.fillDisc(5.5, 44.5, 4, 4); // unstencilled, clipped by the edge
r.fillRect(30.49, 24.51, 3, 5, 3); // stencilled by the disc
r.fillRect(1, 1, 0, 6); // size 0 draws nothing
return { w: r.w, h: r.h, buf: Array.from(r.buf) };
})(),
paletteRgb: ['#12141c', '#b07a5a', '#7a4f3a', '#24161a', '#d9cfc2',
'#c9c3b4', '#4a5468', '#171a22', '#3a2a22'].map(hexToRgb),
};
const here = dirname(fileURLToPath(import.meta.url));
const path = join(here, 'oracle.json');
writeFileSync(path, JSON.stringify(out));
console.log(`oracle: ${FRAMES} frames -> ${path}`);

16
mise.toml Normal file
View file

@ -0,0 +1,16 @@
# Toolchain for both halves of the repo: the CLJS frontend and the Django
# backend. `mise install` from the repo root is the whole setup.
#
# java must be 21+. The current closure-compiler is built for it, and 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.
[tools]
java = "temurin-21"
node = "20"
clojure = "latest"
python = "3.12"
[env]
_.python.venv = { path = ".venv", create = true }