From eb06be005c13d02b75c3715e81ca10374519d425 Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Sun, 27 Sep 2026 14:43:34 -0400 Subject: [PATCH] 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 --- .gitignore | 10 + docs/animation-model.md | 351 ++++ docs/architecture.md | 869 +++++++++ docs/port-plan.md | 321 ++++ frontend/README.md | 76 + frontend/package-lock.json | 1624 +++++++++++++++++ frontend/package.json | 17 + frontend/public/index.html | 24 + frontend/shadow-cljs.edn | 41 + frontend/src/arthur/core.cljs | 19 + frontend/src/arthur/domain/geom.cljs | 130 ++ frontend/src/arthur/domain/landmarks.cljs | 115 ++ frontend/src/arthur/domain/palette.cljs | 50 + frontend/src/arthur/domain/raster.cljs | 150 ++ frontend/src/arthur/domain/ring.cljs | 96 + frontend/test/arthur/domain/geom_test.cljs | 157 ++ .../test/arthur/domain/landmarks_test.cljs | 73 + frontend/test/arthur/domain/raster_test.cljs | 133 ++ frontend/test/arthur/domain/ring_test.cljs | 107 ++ frontend/test/arthur/parity_test.cljs | 200 ++ frontend/test/arthur/synth.cljs | 173 ++ frontend/test/arthur/synth_test.cljs | 69 + frontend/test/parity/.gitignore | 4 + frontend/test/parity/oracle.mjs | 107 ++ mise.toml | 16 + 25 files changed, 4932 insertions(+) create mode 100644 docs/animation-model.md create mode 100644 docs/architecture.md create mode 100644 docs/port-plan.md create mode 100644 frontend/README.md create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/index.html create mode 100644 frontend/shadow-cljs.edn create mode 100644 frontend/src/arthur/core.cljs create mode 100644 frontend/src/arthur/domain/geom.cljs create mode 100644 frontend/src/arthur/domain/landmarks.cljs create mode 100644 frontend/src/arthur/domain/palette.cljs create mode 100644 frontend/src/arthur/domain/raster.cljs create mode 100644 frontend/src/arthur/domain/ring.cljs create mode 100644 frontend/test/arthur/domain/geom_test.cljs create mode 100644 frontend/test/arthur/domain/landmarks_test.cljs create mode 100644 frontend/test/arthur/domain/raster_test.cljs create mode 100644 frontend/test/arthur/domain/ring_test.cljs create mode 100644 frontend/test/arthur/parity_test.cljs create mode 100644 frontend/test/arthur/synth.cljs create mode 100644 frontend/test/arthur/synth_test.cljs create mode 100644 frontend/test/parity/.gitignore create mode 100644 frontend/test/parity/oracle.mjs create mode 100644 mise.toml diff --git a/.gitignore b/.gitignore index caf400b..f2a6ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/animation-model.md b/docs/animation-model.md new file mode 100644 index 0000000..33a6ab5 --- /dev/null +++ b/docs/animation-model.md @@ -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: }` or `{a: 1, k: []}`. 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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e872ce5 --- /dev/null +++ b/docs/architecture.md @@ -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/` with `If-Match: `, + 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. diff --git a/docs/port-plan.md b/docs/port-plan.md new file mode 100644 index 0000000..d2c8576 --- /dev/null +++ b/docs/port-plan.md @@ -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. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..b4fd966 --- /dev/null +++ b/frontend/README.md @@ -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 **** — 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. +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..debf393 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1624 @@ +{ + "name": "arthur-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "arthur-frontend", + "version": "0.0.1", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "shadow-cljs": "^2.28.21" + } + }, + "node_modules/asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/asn1.js/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.5.1.tgz", + "integrity": "sha512-zzw1uCAgLbsKwBfFc8CX78DDg+xZeBksSO3vwVIDDN5i94eOrPsSSyiVhmsSABFDM/OcpE2aagCat9dnWQLG1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "object.assign": "^4.1.4", + "util": "^0.10.4" + } + }, + "node_modules/assert/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/assert/node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/browserify-aes": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "node_modules/browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/browserify-rsa": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz", + "integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^5.2.1", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-sign": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.6.tgz", + "integrity": "sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg==", + "dev": true, + "license": "ISC", + "dependencies": { + "bn.js": "^5.2.3", + "browserify-rsa": "^4.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "elliptic": "^6.6.1", + "inherits": "^2.0.4", + "parse-asn1": "^5.1.9", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cipher-base": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.7.tgz", + "integrity": "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/console-browserify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", + "integrity": "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==", + "dev": true + }, + "node_modules/constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-ecdh": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz", + "integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "elliptic": "^6.5.3" + } + }, + "node_modules/create-ecdh/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "node_modules/crypto-browserify": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz", + "integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserify-cipher": "^1.0.1", + "browserify-sign": "^4.2.3", + "create-ecdh": "^4.0.4", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "diffie-hellman": "^5.0.3", + "hash-base": "~3.0.4", + "inherits": "^2.0.4", + "pbkdf2": "^3.1.2", + "public-encrypt": "^4.0.3", + "randombytes": "^2.1.0", + "randomfill": "^1.0.4" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "node_modules/diffie-hellman/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4", + "npm": ">=1.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/elliptic": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", + "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.11.9", + "brorand": "^1.1.0", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.1", + "inherits": "^2.0.4", + "minimalistic-assert": "^1.0.1", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/elliptic/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-base": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz", + "integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "node_modules/https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, + "node_modules/miller-rabin/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-libs-browser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", + "integrity": "sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^3.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.1", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.11.0", + "vm-browserify": "^1.0.1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-asn1": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz", + "integrity": "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "asn1.js": "^4.10.1", + "browserify-aes": "^1.2.0", + "evp_bytestokey": "^1.0.3", + "pbkdf2": "^3.1.5", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/path-browserify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz", + "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pbkdf2": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.6.tgz", + "integrity": "sha512-BT6eelPB1EyGHo8pC0o9Bl6k6SYVhKO1jEbd3lcTrtr7XHdjP8BW1YpfCV3G9Kwkxgattk+S5q2/RvuttCsS1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "ripemd160": "^2.0.3", + "safe-buffer": "^5.2.1", + "sha.js": "^2.4.12", + "to-buffer": "^1.2.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/public-encrypt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", + "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "node_modules/public-encrypt/node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/readline-sync": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", + "integrity": "sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ripemd160": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", + "integrity": "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-base": "^3.1.2", + "inherits": "^2.0.4" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ripemd160/node_modules/hash-base": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.2.tgz", + "integrity": "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^2.3.8", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shadow-cljs": { + "version": "2.28.23", + "resolved": "https://registry.npmjs.org/shadow-cljs/-/shadow-cljs-2.28.23.tgz", + "integrity": "sha512-SM7LeLctZLLCm6Y3NxWOH4GvHqHDZ6Jz9bUgfpJrk1jMADqIp3rliD6Rrd12gLX2b9/oEh6UyD7X+yw6O1++sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "node-libs-browser": "^2.2.1", + "readline-sync": "^1.4.7", + "shadow-cljs-jar": "1.3.4", + "source-map-support": "^0.4.15", + "which": "^1.3.1", + "ws": "^7.4.6" + }, + "bin": { + "shadow-cljs": "cli/runner.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/shadow-cljs-jar": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/shadow-cljs-jar/-/shadow-cljs-jar-1.3.4.tgz", + "integrity": "sha512-cZB2pzVXBnhpJ6PQdsjO+j/MksR28mv4QD/hP/2y1fsIa9Z9RutYgh3N34FZ8Ktl4puAXaIGlct+gMCJ5BmwmA==", + "dev": true, + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "^0.5.6" + } + }, + "node_modules/stream-browserify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", + "integrity": "sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "node_modules/stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/timers-browserify": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", + "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "setimmediate": "^1.0.4" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/util": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", + "integrity": "sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/vm-browserify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", + "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..72b27f2 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..3465e6d --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,24 @@ + + + + + + + arthur + + + +
+ + + diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn new file mode 100644 index 0000000..1713ebb --- /dev/null +++ b/frontend/shadow-cljs.edn @@ -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$"}}} diff --git a/frontend/src/arthur/core.cljs b/frontend/src/arthur/core.cljs new file mode 100644 index 0000000..73d4330 --- /dev/null +++ b/frontend/src/arthur/core.cljs @@ -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)) diff --git a/frontend/src/arthur/domain/geom.cljs b/frontend/src/arthur/domain/geom.cljs new file mode 100644 index 0000000..21baec1 --- /dev/null +++ b/frontend/src/arthur/domain/geom.cljs @@ -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))))) diff --git a/frontend/src/arthur/domain/landmarks.cljs b/frontend/src/arthur/domain/landmarks.cljs new file mode 100644 index 0000000..fa93bdb --- /dev/null +++ b/frontend/src/arthur/domain/landmarks.cljs @@ -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) diff --git a/frontend/src/arthur/domain/palette.cljs b/frontend/src/arthur/domain/palette.cljs new file mode 100644 index 0000000..101496e --- /dev/null +++ b/frontend/src/arthur/domain/palette.cljs @@ -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)) diff --git a/frontend/src/arthur/domain/raster.cljs b/frontend/src/arthur/domain/raster.cljs new file mode 100644 index 0000000..8a13e07 --- /dev/null +++ b/frontend/src/arthur/domain/raster.cljs @@ -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}))) diff --git a/frontend/src/arthur/domain/ring.cljs b/frontend/src/arthur/domain/ring.cljs new file mode 100644 index 0000000..57a1a50 --- /dev/null +++ b/frontend/src/arthur/domain/ring.cljs @@ -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))) diff --git a/frontend/test/arthur/domain/geom_test.cljs b/frontend/test/arthur/domain/geom_test.cljs new file mode 100644 index 0000000..5d30016 --- /dev/null +++ b/frontend/test/arthur/domain/geom_test.cljs @@ -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))))))) diff --git a/frontend/test/arthur/domain/landmarks_test.cljs b/frontend/test/arthur/domain/landmarks_test.cljs new file mode 100644 index 0000000..3dc75cf --- /dev/null +++ b/frontend/test/arthur/domain/landmarks_test.cljs @@ -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")) diff --git a/frontend/test/arthur/domain/raster_test.cljs b/frontend/test/arthur/domain/raster_test.cljs new file mode 100644 index 0000000..b3d8a97 --- /dev/null +++ b/frontend/test/arthur/domain/raster_test.cljs @@ -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)))) diff --git a/frontend/test/arthur/domain/ring_test.cljs b/frontend/test/arthur/domain/ring_test.cljs new file mode 100644 index 0000000..ec50929 --- /dev/null +++ b/frontend/test/arthur/domain/ring_test.cljs @@ -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"))) diff --git a/frontend/test/arthur/parity_test.cljs b/frontend/test/arthur/parity_test.cljs new file mode 100644 index 0000000..f14a988 --- /dev/null +++ b/frontend/test/arthur/parity_test.cljs @@ -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))) diff --git a/frontend/test/arthur/synth.cljs b/frontend/test/arthur/synth.cljs new file mode 100644 index 0000000..fa2eb52 --- /dev/null +++ b/frontend/test/arthur/synth.cljs @@ -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))))))) diff --git a/frontend/test/arthur/synth_test.cljs b/frontend/test/arthur/synth_test.cljs new file mode 100644 index 0000000..00e7310 --- /dev/null +++ b/frontend/test/arthur/synth_test.cljs @@ -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"))) diff --git a/frontend/test/parity/.gitignore b/frontend/test/parity/.gitignore new file mode 100644 index 0000000..8e8c2b9 --- /dev/null +++ b/frontend/test/parity/.gitignore @@ -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 diff --git a/frontend/test/parity/oracle.mjs b/frontend/test/parity/oracle.mjs new file mode 100644 index 0000000..05b42cf --- /dev/null +++ b/frontend/test/parity/oracle.mjs @@ -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}`); diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..efa7f08 --- /dev/null +++ b/mise.toml @@ -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 }