Port step 5: freeze measured mouth into playable channels

This commit is contained in:
Olive Vaughn 2026-09-27 19:01:39 -04:00
parent 942e2f38ab
commit 8a06835895
24 changed files with 1625 additions and 506 deletions

View file

@ -7,30 +7,50 @@
into this map and every mounted layer-2 sub compares the result.
So the scene is here (it is a document — a human placed every node) and dense
channel blocks are not; they live behind a handle in `store`. Today the demo
scene has no dense blocks and the store is empty, which is why it is a map and
not yet a namespace."
channel blocks are not; they live behind a handle in `store`. The hand-written
demo scene has no dense blocks and its store is empty; the swarm and the take
are entirely dense."
(:require [arthur.demo :as demo]
[arthur.demo.swarm :as swarm]))
[arthur.demo.swarm :as swarm]
[arthur.demo.take :as take]))
(defn- clip
"A scene plus the clip-level facts the transport and the stage need.
Read OFF the scene rather than written again beside it. `:fps`, `:frames` and the
stage dimensions belong to the CLIP and not to the timeline — a timeline has a
frame space, not a rate and not a size — and they sit on the scene map only
because there is one clip per scene today. Copying them by hand into this table
is how one of them comes to disagree with the scene it describes."
[label scene store]
(merge {:label label :scene scene :store store}
(select-keys scene [:fps :frames :width :height])))
(def scenes
"Two hand-made clips, selectable from the transport.
"The hand-made clips, selectable from the transport.
`:swarm` holds its geometry in DENSE blocks behind store handles, which is the
shape freeze produces at step 5 — so the fun one is also the load test."
{:demo {:label "demo" :scene demo/scene :store nil
:fps demo/fps :frames demo/frames}
:swarm {:label "swarm" :scene @swarm/scene :store @swarm/store
:fps swarm/fps :frames swarm/frames}})
`:swarm` is the load test: a hundred and twenty nodes, entirely dense. The two
takes are step 5's deliverable and they are ONE freeze — the same blocks, with
`:head` written as a dense track in one and as framed identity in the other, so
the button that switches between them switches a document field and nothing
else."
{:demo (clip "demo" demo/scene nil)
:swarm (clip "swarm" @swarm/scene @swarm/store)
:take (clip "take" @take/scene @take/store)
:take-locked (clip "locked" @take/locked @take/store)})
(def default
{;; --- the document ---
:scene/current :demo
:scene/current :take
:palette :arthur/default ; a NAME; the ramp itself is project data
;; --- the clip ---
:clip {:fps demo/fps
:frames demo/frames}
;;
;; Including the STAGE DIMENSIONS, which are the project's and not the
;; footage's. That is what deleting `makeXform` buys — the framing became a
;; transform on a node, so nothing downstream of the freeze knows the frame
;; size — and it is why ui/player no longer hardcodes 320x200.
:clip (select-keys (:take scenes) [:fps :frames :width :height])
;; --- transport ---
;;

View file

@ -13,9 +13,6 @@
(def scene (reader/read-string source))
(def width 320)
(def height 200)
(def fps (:fps scene))
(def frames (:frames scene))

View file

@ -26,6 +26,12 @@
;; frame space, not a rate — and it is here only because there is one clip.
:frames 229
:fps 30
;; The STAGE, in pixels. The project's dimensions, not the footage's — which is
;; what makes `makeXform` deletable: placement is a transform on a node and the
;; stage clips whatever hangs off. Here everything is authored in stage pixels
;; already, because a hand-written scene is a painted one.
:width 320
:height 200
:nodes
{;; The clip root. EXPOSURE LIVES HERE and is inherited, because

View file

@ -152,6 +152,8 @@
{:name "swarm"
:frames frames
:fps fps
:width 320
:height 200
:nodes
(into {:root {:id :root :kind :group :parent nil :z "a1"
;; On 2s, like everything else. A hundred and twenty shapes

View file

@ -0,0 +1,112 @@
(ns arthur.demo.take
"The synthetic take: the whole vertical slice, with no video file in it.
This is port-plan step 5's deliverable and it is the first thing in the tree
that runs every stage in order —
synth ──▶ measure/anchor ──▶ condition/anchor
│ │
└──▶ measure/mouth ◀─┘
│
condition/contours
│
FREEZE ──▶ channels on nodes
│
scene/resolver ──▶ raster
— and the order of that diagram is the whole argument for the stage split. The
anchor fit is knob-free. Conditioning smooths its four parameters. The rings are
then measured THROUGH the conditioned transform, so `anchor avg` does re-run the
ring mapping — a few hundred frames of twenty points, free — and does not re-run
anything that reads a source pixel, because that part takes the landmarks and
the frames and never the transform.
TWO SCENES, ONE STORE. `:take` carries the head as filmed and `:take-locked`
carries it locked, and they are the same dense blocks with one node's channels
written two ways. That is the claim \"stabilisation is a channel, not a mode\"
made checkable by eye: switching between them is a document edit, tier 1, and
not one byte of tier 2 differs."
(:require [arthur.flow.condition :as condition]
[arthur.flow.freeze :as freeze]
[arthur.flow.measure.anchor :as anchor]
[arthur.flow.measure.mouth :as mouth]
[arthur.synth :as synth]))
(def frames 229)
(def fps 30)
(def ^:private stage
;; The project's dimensions, and NOT the footage's. This is what deleting
;; `makeXform` buys: the head is placed and scaled on the stage by a transform
;; on a node, so a 1440x1920 portrait clip and a 320x200 stage are not a
;; conflict to resolve. Whatever hangs off the edge is clipped.
[320 200])
(def ^:private aspect
;; The synth writes x and y in the SAME unit, so its normalised space is already
;; isotropic and the anisotropy correction is the identity here. Real footage
;; passes W/H from the manifest at step 6. Worth knowing while reading anything
;; here: aspect 1 is the one setting at which a port that dropped the
;; anisotropy correction entirely would still look right, which is why
;; anchor-test exercises 0.5625 and this does not.
1)
(def ^:private knobs
"The prototype's own defaults, from index.html, so the first thing anyone sees
is the thing it was tuned to look like."
{:anchor-avg 2 ; smoothWin
:contour-avg 1 ; contourSmooth
:verts 8 ; vertices
:aperture-cut 0.12}) ; apertureThresh 120/1000
(def analysis
"Stage 2's output, synthesised. A seeded generator, so a wrong pose is
reproducible rather than something that happened once."
(delay (synth/synth-dense frames {:seed 1})))
(def measured
"Stages 3 and 4, in the order the stage split requires."
(delay
(let [dense @analysis
fitted (anchor/fit {:aspect aspect} {:dense dense})
anchored (condition/anchor knobs fitted)
rings (mouth/measure {:aspect aspect}
{:dense dense :transforms (:transforms anchored)})]
(assoc anchored
:outer (condition/contours knobs (:outer rings))
:inner (condition/contours knobs (:inner rings))
;; NOT smoothed. The aperture is the inner ring's own height read off
;; as a scalar, and `contour avg` is a spatial-correspondence
;; smoother over a ring; running it over a scalar would be a second,
;; undocumented low-pass on a signal that then decides visibility.
:aperture (:aperture rings)))))
(def params
"What the freeze was handed. Public because it is the honest way to re-freeze at
other settings — a test that built its own copy would be asserting about a clip
nobody looks at."
(merge knobs
{:name "take"
:fps fps
:stage stage
;; On 2s. docs/design.md is emphatic that everything rides ONE grid: a
;; head cutting on odd frames against a mouth cutting on even ones reads
;; as two performances, so exposure lives on the clip root and inherits.
:expose 2
;; The head as filmed. `:take-locked` is the same freeze with this one
;; field changed, which is the point.
:head :as-filmed
;; Provenance. A content hash once the analysis is an artifact the
;; backend stores; until then, honest about what it actually is.
:analysis "synth:mulberry32/seed-1"}))
(def frozen
(delay (freeze/clip params @measured)))
(def store (delay (:store @frozen)))
(def scene (delay (:scene @frozen)))
(def locked
"The same blocks, with `:head` written as framed identity instead."
(delay (freeze/head-mode {:mode :locked} @frozen)))

View file

@ -109,11 +109,18 @@
;; ---------------------------------------------------------------------------
;; dense blocks
(defn- dense-state
[state f]
(if (nil? state)
present
(aget state f)))
(defn- absent-at?
"Is the subject absent on frame f of this block's slice?
INDEXED THE WAY THE DATA IS. A block is node-major — offset(node i) =
i·frames·stride — so a block holding several nodes holds several mask regions,
and the one belonging to this channel starts at offset/stride. Indexing the
mask by f alone reads the FIRST node's absence for every node in the block,
which is not a subtly wrong pose: it is every part in the block vanishing on
the frames where one of them was occluded."
[state offset stride f]
(and (some? state)
(pos? (bit-and (aget state (+ (quot offset stride) f)) absent-bit))))
(defn dense-at
"Read frame f out of a dense block.
@ -130,21 +137,37 @@
stride 1 yields a number; anything wider yields a SUBARRAY VIEW over the
block, not a copy. Fixed topology is what makes that possible — the frame's
data is a rectangular slice at a known offset with no per-frame header."
[{:keys [store offset stride] nf :frames} f st]
(let [{:keys [data state]} (get st store)]
(when (nil? data)
(throw (ex-info "dense channel's store key is not in the store"
{:store store :have (vec (sort (map str (keys st))))})))
(let [f (-> f (max 0) (min (dec nf)))
sm (dense-state state f)]
(cond
(pos? (bit-and sm absent-bit)) absent
:else
(let [o (+ offset (* f stride))]
(if (= 1 stride)
(aget data o)
(.subarray data o (+ o stride))))))))
data is a rectangular slice at a known offset with no per-frame header.
FIXED POINT. `:scale` in the block header means the stored integers are the
value times that scale, so a block of geometry in image-height units fills an
Int16 usefully and a block of stage pixels — which wants a different scale
entirely — fills one too. It is in the header rather than agreed by convention
for exactly that reason, and it is why the block in memory is byte for byte the
block on the wire: a handle that names a sha256 has to name the bytes you
actually hold.
Decoding costs the view. `out` is a stride-sized destination the caller owns —
`cursor` allocates one per channel — because a copy per node per frame is the
allocation this whole model is arranged to avoid; passing nil allocates, which
is what `value-at`, the specification, does."
([blk f st] (dense-at blk f st nil))
([{:keys [store offset stride scale] nf :frames} f st out]
(let [{:keys [data state]} (get st store)]
(when (nil? data)
(throw (ex-info "dense channel's store key is not in the store"
{:store store :have (vec (sort (map str (keys st))))})))
(let [f (-> f (max 0) (min (dec nf)))]
(if (absent-at? state offset stride f)
absent
(let [o (+ offset (* f stride))]
(cond
(= 1 stride) (let [v (aget data o)] (if scale (/ v scale) v))
(nil? scale) (.subarray data o (+ o stride))
:else (let [dst (or out (js/Float64Array. stride))]
(dotimes [k stride]
(aset dst k (/ (aget data (+ o k)) scale)))
dst))))))))
;; ---------------------------------------------------------------------------
;; the specification
@ -197,20 +220,28 @@
(recur (inc mid) hi mid)
(recur lo (dec mid) best))))))
(deftype Cursor [ch ks store ^:mutable i]
(deftype Cursor [ch ks store buf ^:mutable i]
Object
(toString [_] (str "#Cursor{" (pr-str (if ks :keyed (if (:dense ch) :dense :framed))) " i=" i "}")))
(defn cursor
"A reading head on one channel. Build once per channel per resolver, then
`sample!` it per frame. Holds the sorted key index, which is why the index is
built here and not in the document."
built here and not in the document — and the decode buffer a fixed-point block
needs, for the same reason the resolver owns one point buffer per node.
Only a wide fixed-point block gets a buffer: a stride-1 block decodes to a
number and a block with no `:scale` is handed back as a view."
([ch] (cursor ch nil))
([ch store]
(check-unimplemented! ch)
(->Cursor ch (when (and (:animated? ch) (not (:dense ch)) (seq (:keys ch)))
(frames ch))
store 0)))
(let [d (:dense ch)]
(->Cursor ch
(when (and (:animated? ch) (not d) (seq (:keys ch))) (frames ch))
store
(when (and d (:scale d) (> (:stride d) 1))
(js/Float64Array. (:stride d)))
0))))
(defn sample!
"Value of the cursor's channel at f. O(1) when f is at or one key past where
@ -222,7 +253,7 @@
ks (.-ks cur)]
(cond
(not (:animated? ch)) (:value ch)
(:dense ch) (dense-at (:dense ch) f (.-store cur))
(:dense ch) (dense-at (:dense ch) f (.-store cur) (.-buf cur))
(nil? ks) absent ; animated with an empty key map
:else
(let [n (count ks)
@ -281,5 +312,14 @@
" requires hold of every cut part and tweening reads as puppet software"))
(and (map? ch) (seq (:over ch)))
(conj ":over layers are not implemented (port-plan step 2 scope)")))
(conj ":over layers are not implemented (port-plan step 2 scope)")
;; A scale of zero divides every value in the block by zero, and a negative
;; one mirrors the geometry. Both are authored-data bugs that present as a
;; part drawn nowhere or inside out, not as an error.
(and (map? ch) (:dense ch) (contains? (:dense ch) :scale)
(not (and (number? (:scale (:dense ch))) (pos? (:scale (:dense ch))))))
(conj (str ":dense :scale is " (pr-str (:scale (:dense ch)))
" — a fixed-point scale is a positive number the stored integers"
" were multiplied by"))))

View file

@ -190,6 +190,30 @@
(ch/nothing? skw) (ch/nothing? anc))
[pos rot scl skw anc])))
(defn- visible?
"Is the node switched on this frame?
`[:vis]` IS A BOOLEAN, and this insists on it rather than testing truthiness,
because the two obvious implementations are both wrong about a DENSE `[:vis]`.
A dense block yields 0 or 1, and 0 is TRUTHY in CLJS — so `(if v …)` shows a
hidden frame, and `(true? v)` hides every frame. Neither reads as an error.
docs/animation-model.md's parts table says `:mouth-in` carries `[:vis]` dense;
flow/freeze writes it KEYED, because a threshold crossing is a handful of
transitions and hold is the default, and because a human has to be able to fix
one frame of it. When something does want a dense one it will land here loudly
instead of blanking the scene.
Absence is not a boolean and is not an error: a subject that is not on the
frame has nothing to show."
[id v]
(cond
(true? v) true
(false? v) false
(ch/nothing? v) false
:else (throw (ex-info "[:vis] must sample to a boolean"
{:node id :value v}))))
(defn- place
"Where a node sits this frame, as {:m world :f local-frame :rd reader}, or nil
when it is not on the frame at all.
@ -210,7 +234,7 @@
chs (node/channels n)
lf (node/local-frame n pf)
rd (fn [path] (read id path (get chs path) lf))]
(when (true? (rd [:vis]))
(when (visible? id (rd [:vis]))
(when-let [[pos rot scl skw anc] (xform-at rd)]
;; dest aliases `local` here, which mul! allows: it reads both
;; operands fully before writing either.

View file

@ -92,9 +92,12 @@
;; Changing the clip changes the resolver, the frame count and the rate all
;; at once, so the playhead goes home rather than being left pointing at a
;; frame the new clip may not have.
(let [{:keys [fps frames]} (get db/scenes id)]
(let [{:keys [fps frames] :as clip} (get db/scenes id)]
{:db (-> db
(assoc :scene/current id)
(assoc :clip {:fps fps :frames frames})
;; The stage travels with the clip: two clips may be different
;; sizes, and the raster the loop paints into is the clip's, not
;; the app's.
(assoc :clip (select-keys clip [:fps :frames :width :height]))
(assoc-in [:playback :frame] 0))
::seek! [fps frames 0]})))

View file

@ -0,0 +1,440 @@
(ns arthur.flow.freeze
"Stage 5, the freeze: measurements become CHANNELS.
This is the hinge the whole model turns on. 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 between rotoscoped and hand-authored is a flag\"
literally true: what comes out of here is the same `:channels` map a hand fills
in sparsely, and the flag is `:generated`, which nothing in the renderer reads.
Three conversions happen here and nowhere else.
MAPS BECOME FLAT. `flow/measure/*` speaks {:x :y}, because it is the numeric
oracle and a faithful port was worth more there than a fast one. A channel
value is FLAT — [x0 y0 x1 y1 …] — in authored vectors and dense blocks alike.
`rings->flat` is the only place that crossing is made.
FLOATS BECOME FIXED POINT. Geometry goes into an Int16 block with the scale in
its header; see `geom-scale`.
A SIMILARITY BECOMES THREE CHANNELS. `{s θ tx ty}` IS `[:xform :scale]`,
`[:xform :rot]` and `[:xform :pos]`, so the anchor drops onto `:head` with no
adapter — which is the sign the decomposition is the right one.
`makeXform` IS NOT HERE AND IS NOT COMING. The prototype centres on the face
oval's bbox and zooms until the face is 80% of the raster height, so every
stored vertex carries a cropping decision made once, at analysis time, from one
frame's landmarks. Here the geometry stays in the node's own local space and the
framing is `[:xform :*]` on an authored `:face` node, which the stage clips.
Project dimensions are therefore independent of the footage — see
`face-placement`, and \"What space geometry is in\" in docs/animation-model.md.
Not `flow/key`. A traced mouth costs nothing, so it gets a key on EVERY frame;
only a plate, which a human draws, is worth decimating. The one stage-5 policy
the mouth does want is the aperture threshold, and that is `visibility` below."
(:require [arthur.domain.channel :as ch]
[arthur.domain.geom :as geom]
[arthur.domain.ring :as ring]))
;; ---------------------------------------------------------------------------
;; fixed point
(def ^:const geom-scale
"Q14: the stored integer is the value times 16384.
Geometry is head-local and its unit is ONE IMAGE HEIGHT, so 16384 covers ±2
image heights in an Int16 and quantises to 1/16384 = 6.1e-5 of an image height.
Against a face placed so that its 0.29-image-height oval fills most of a 200px
stage — around 850 stage pixels per image height — that is 0.05px, two orders
below anything the rasteriser can express.
A power of two, so the decode is a floating-point exact division and freezing
the same numbers twice cannot drift.
It is a CONSTANT here and a FIELD in the block header, and the difference
matters: a painted cel's geometry is in stage pixels, where ±2 would be absurd
and 1/16384 of a pixel is waste. Each block says what its own space needs."
16384)
(def ^:private ^:const int16-max 32767)
;; ---------------------------------------------------------------------------
;; blocks
(defn- pack
"Tracks -> one dense block, NODE-MAJOR and FRAME-MINOR:
offset(track i) = i · frames · stride
value(i, f) = data[offset(i) + f · stride]
No per-frame header and no indirection, which FIXED TOPOLOGY is what buys: every
frame of a part carries the same component count with the same meanings, so a
frame is a rectangular slice at an arithmetic offset. A variable vertex count
would force an offset table and a scan per frame, so the aesthetic constraint is
a performance asset rather than a cost. `demo/swarm` holds the same layout and
is the load test for it.
`:ctor` makes the array — a function and not a type, because `new` is not
something a value can carry — `:scale` is the fixed-point scale or nil, and
`:absent` an optional per-frame predicate. Absence is the SUBJECT's, not the
part's, so it is written into every track of the block: the mouth outline and
the mouth interior are one face, and a frame that face was not on has no pose
for either.
Written with `dotimes` and `aset` rather than as a fold, and that is the
exception rather than the rule in this codebase: the destination is a typed
array, so there is nothing to accumulate into and a collection idiom here would
allocate a seq per frame to throw away."
[{:keys [ctor scale absent]} tracks]
(let [n (count tracks)
nf (count (first tracks))
stride (count (first (first tracks)))
data (ctor (* n nf stride))
state (when absent (js/Uint8Array. (* n nf)))]
(dotimes [i n]
(let [track (vec (nth tracks i))
base (* i nf stride)]
(dotimes [f nf]
(let [vs (nth track f)
o (+ base (* f stride))]
(dotimes [k stride]
(let [v (nth vs k)]
(aset data (+ o k)
(if scale
(let [q (js/Math.round (* v scale))]
;; Saturating would read as articulation flattening off
;; at the extremes — a bad detection, not a bad scale.
(when (> (abs q) int16-max)
(throw (ex-info "value does not fit the block's fixed point"
{:value v :scale scale :quantised q
:track i :frame f :component k})))
q)
v))))
(when (and state (absent f))
(aset state (+ (* i nf) f) ch/absent-bit))))))
{:data data :state state :stride stride :frames nf :scale scale
:offsets (mapv #(* % nf stride) (range n))}))
(defn- dense
"Track i of a packed block, as a DENSE channel definition."
[store-key blk i generated]
{:animated? true :interp :hold
:dense (cond-> {:store store-key
:offset (nth (:offsets blk) i)
:stride (:stride blk)
:frames (:frames blk)}
(:scale blk) (assoc :scale (:scale blk)))
:generated generated
:over []})
;; ---------------------------------------------------------------------------
;; geometry
(defn rings->flat
"Ring track -> one flat [x0 y0 x1 y1 …] per frame, at the vertex budget.
The vertex budget is a fixed-index SUBSAMPLE, never adaptive decimation: slot k
means the same anatomy on every frame of the shot, and that is what makes
temporal correspondence possible at all. It is applied here, after stage 4's
contour average, and the two commute because both are per-slot — which is the
whole reason the vertex knob can sit downstream of the smoothing knob instead
of alongside it. `mouth-test` asserts that rather than leaving it to look
obvious.
An ODD budget is refused. It lands off the cardinal slots — the corners and the
lip centres — and a wrongly-ordered ring self-intersects INVISIBLY at odd vertex
counts and obviously at even ones, so an odd budget is the one setting at which
the simplicity assertion stops protecting anything."
[rings verts]
(let [len (count (first rings))]
(when-not (and (integer? verts) (even? verts) (>= verts 4) (<= verts len))
(throw (ex-info "vertex budget must be even and between 4 and the ring's slot count"
{:verts verts :slots len})))
(let [slots (ring/subsample-slots len verts)]
(mapv (fn [r]
(into [] (mapcat (fn [s] (let [p (nth r s)] [(:x p) (:y p)]))) slots))
rings))))
;; ---------------------------------------------------------------------------
;; the anchor, onto :head
(defn invert
"The inverse of a similarity, STILL FACTORED.
The anchor fit maps each frame onto the shot's mean pose, so it is what takes
the head's motion OUT; geometry is stored in the space it produces. Putting the
head's motion back — the \"as filmed\" mode — is therefore the fit's inverse, and
it has to stay a {s θ t} rather than becoming a matrix, because the three
components land on three independently keyframable channels and interpolating
matrix entries is meaningless.
p ↦ s·R(θ)·p + t inverts to q ↦ (1/s)·R(-θ)·(q - t)"
[{:keys [s theta tx ty]}]
(let [s' (/ 1.0 s)
c (js/Math.cos theta)
sn (js/Math.sin theta)]
{:s s'
:theta (- theta)
:tx (* (- s') (+ (* c tx) (* sn ty)))
:ty (* (- s') (+ (* (- sn) tx) (* c ty)))}))
(def ^:private head-modes #{:locked :as-filmed :per-plate})
(defn head-mode
"Rewrite `:head`'s transform channels into one of the three shapes.
STABILISATION IS A CHANNEL, NOT A MODE. `{s θ tx ty}` per frame IS
`[:xform :scale]`, `[:xform :rot]` and `[:xform :pos]`, so removing the head's
motion is not a pipeline setting — it is a question of which shape one node's
channels carry:
:locked framed identity — the head sits still, for tracing and for
judging articulation
:as-filmed dense — the head moves around the stage
:per-plate keyed at the kept frames — the head pose is stable for exactly as
long as a drawing is on screen, which is what a plate strip wants
ALWAYS MEASURE, ALWAYS STORE FACTORED, whatever the toggle says. The blocks are
written once and every mode reads them or ignores them; making this an
analysis-time switch would lose two things downstream. \"Smooth the transform,
never the contour\" only means anything while the two are separate. And a
velocity minimum is \"articulation paused\" in head-local space and \"the head
happened to be still\" in image space, so key selection needs the split to exist
in storage.
So this is a DOCUMENT EDIT: tier 1, undoable, syncable, instant, and not a
reason to re-analyse. It rewrites `:head` and touches nothing else — in
particular not `:face`, which a hand placed, and not one byte of any block.
`:per-plate` samples the dense track at the kept frames and stores plain
vectors. It may not store what `value-at` handed it: a wide dense read is a
VIEW into the block, and a view into tier 2 sitting in the document is a value
that changes when a re-freeze rewrites the array under it."
[{:keys [mode kept]} {:keys [scene store]}]
(when-not (contains? head-modes mode)
(throw (ex-info "head mode is not one of the three channel shapes"
{:mode mode :modes head-modes})))
(let [base (get-in scene [:nodes :head :measured])
at (fn [path f]
(let [v (ch/value-at (get base path) f store)
n (:stride (:dense (get base path)))]
(if (= 1 n) v (mapv #(ch/component v %) (range n)))))
keys-of (fn [path]
(-> (get base path)
(dissoc :dense)
(assoc :keys (into {} (map (juxt identity #(at path %))) (sort kept)))))]
(when (and (= mode :per-plate) (empty? kept))
(throw (ex-info "the per-plate head mode needs a kept-frame set; it is the plate strip's, not measurement's"
{:mode mode})))
(assoc-in scene [:nodes :head :channels]
(case mode
;; No `:generated` on the locked shape, and that is not an
;; oversight: nothing generated this identity. It is a decision,
;; and provenance that claimed otherwise would offer a re-freeze
;; button that could only undo it.
:locked {[:xform :pos] (ch/framed [0.0 0.0])
[:xform :rot] (ch/framed 0.0)
[:xform :scale] (ch/framed [1.0 1.0])}
:as-filmed base
:per-plate (into {} (map (juxt key #(keys-of (key %)))) base)))))
;; ---------------------------------------------------------------------------
;; the aperture, onto [:vis] of :mouth-in
(defn visibility
"The aperture track -> `[:vis]` keys on the mouth interior.
`flow/measure/mouth` reports the aperture and deliberately does not threshold
it: the measurement is the inner ring's own height and the threshold is a
policy, which is stage 5. Relative to the take's PEAK aperture, not absolute, so
one number works across faces and framings — the prototype's
`ap.map(v => v / apMax < apertureThresh)`, which lives in its `app.js` and not
in its `pipeline.js`, and is easy to miss when porting from the latter.
KEYED, not dense, although docs/animation-model.md's parts table says dense. A
threshold crossing is a handful of transitions over a take, hold is the default,
and keys are what a human can correct — \"this frame's mouth should be shut\" is
the single most likely hand edit on a lip-sync take, and a dense block in tier 2
is the one shape that cannot receive it. `:generated` still rides along, which is
the point of provenance being on the channel rather than implied by its shape.
A key on frame 0 always, because the first key is the pose the part starts in."
[{:keys [aperture-cut]} {:keys [aperture]} generated]
(let [peak (reduce max aperture)
;; A take with no mouth at all has no peak to be a fraction of. Present
;; rather than absent: an all-zero aperture is a shut mouth, and the
;; interior of a shut mouth is simply not drawn.
shown (mapv (fn [v] (and (pos? peak) (>= (/ v peak) aperture-cut))) aperture)]
(assoc (ch/keyed (into {} (keep (fn [f]
(when (or (zero? f)
(not= (nth shown f) (nth shown (dec f))))
[f (nth shown f)])))
(range (count shown))))
:generated generated)))
;; ---------------------------------------------------------------------------
;; the face, onto the stage
(defn face-placement
"The face's transform on the stage, as FRAMED channels.
AUTHORED, and that is the whole difference from `makeXform`. What comes back is
a DEFAULT — the placement a human would otherwise have to make from scratch on
first open — and from then on it is an ordinary hand-placed transform on an
ordinary node. `makeXform` made the same decision and then baked it into every
vertex, where nothing could ever revise it.
Derived from the reference rigid configuration, which is what the freeze has:
the face oval is not measured, because its only consumers in the prototype were
that transform and the placeholder plate outline. Two numbers:
SCALE is stage pixels per image height, set so the reference's eye-corner span
is 40% of the stage width. Landmark-free — it is the rigid configuration's own
bounding box — and it is a fraction of the STAGE, so a 1440x1920 portrait clip
composited onto a 320x200 stage is not a problem to solve.
ANCHOR is the reference centroid, and this is where `:anchor` earns its place.
MediaPipe's normalised space has its origin at the image's TOP-LEFT CORNER, so
head-local geometry is not centred on anything; the registration point of the
face is the head's own centre, and rotation and scale have to happen about that
rather than about a corner of the footage. Getting that wrong is why hand-placed
parts swing rather than turn.
POSITION puts the anchor a QUARTER of the way down the stage, because the rigid
landmarks are eyes and nose — the upper middle of a face — so a quarter down
leaves the jaw and the mouth on the stage. Whatever hangs off is clipped, which
is not a feature to add: every fill in `domain/raster` clamps already."
[{:keys [stage]} {:keys [ref]}]
(let [[w h] stage
c (geom/centroid ref)
span (- (reduce max (map :x ref)) (reduce min (map :x ref)))
k (/ (* 0.4 w) span)]
{[:xform :anchor] (ch/framed [(:x c) (:y c)])
[:xform :scale] (ch/framed [k k])
[:xform :pos] (ch/framed [(- (/ w 2) (:x c))
(- (* 0.25 h) (:y c))])}))
;; ---------------------------------------------------------------------------
;; the clip
(defn clip
"Conditioned measurements -> a clip: nodes with frozen channels, plus the dense
blocks they read. `(f params inputs)`, no state.
params
:name names the clip and its store keys
:fps the clip's rate. FRAMES is not a parameter — it is
`(count outer)`, because a freeze that could disagree with its
own input about the length of the take would.
:stage [w h] project dimensions, INDEPENDENT of the footage
:expose the clip root's exposure grid, inherited by everything
:verts the lip rings' vertex budget
:aperture-cut fraction of the take's peak aperture below which the mouth
interior is not present
:head :locked | :as-filmed | :per-plate
:kept frames, for :per-plate only
:analysis which analysis artifact these measurements came from
:anchor-avg
:contour-avg the stage-4 knobs. Freeze does not use them; it RECORDS them,
because `:generated` is what lets the UI offer a re-freeze at
different parameters instead of raw keys.
inputs
:ref the Procrustes reference configuration
:transforms the CONDITIONED anchor transforms
:outer :inner the CONDITIONED head-local lip rings
:aperture head-local aperture per frame
:detected optional per-frame booleans; absent frames get the state mask
The node tree is the one docs/animation-model.md specifies, and the two groups
are two different things wanting the same transform:
:root the clip. EXPOSURE LIVES HERE and is inherited strictly.
:face AUTHORED. where the face sits on the stage, and how big.
:head MEASURED. the head's motion, or identity.
:mouth
:mouth-in
`:mouth-in`'s parent is `:mouth` and that transform is identity today, so
composing it is composing identity. It is documented intent, and it is the one
place in the tree where the parent pointer is not yet doing work — the moment a
painted cel rides a moving plate it will be.
`:head` keeps its measured channels under `:measured` as well as in
`:channels`, so `head-mode` can switch shapes without the blocks or the
provenance having to be rebuilt."
[{:keys [name fps stage expose verts aperture-cut head kept
analysis anchor-avg contour-avg] :as params}
{:keys [transforms outer inner detected] :as inputs}]
(let [nf (count outer)
absent (when detected #(not (nth detected % true)))
geom-k (str name "/geom")
head-k #(str name "/head-" %)
prov (fn [by extra]
{:by by :analysis analysis
:params (merge {:anchor-avg anchor-avg} extra)})
rings (pack {:ctor #(js/Int16Array. %) :scale geom-scale :absent absent}
[(rings->flat outer verts) (rings->flat inner verts)])
;; The anchor, inverted and split into its three components. Three blocks
;; and not one: they are three channels, they have three strides, and a
;; single block would need a per-component offset table to say so.
inv (mapv invert transforms)
xf (fn [f] (pack {:ctor #(js/Float32Array. %) :absent absent} [(mapv f inv)]))
pos (xf (fn [t] [(:tx t) (:ty t)]))
rot (xf (fn [t] [(:theta t)]))
scale (xf (fn [t] [(:s t) (:s t)]))
;; Which knobs each channel records is not decoration, it is the
;; invalidation table written down where a re-freeze can read it. The
;; anchor depends on `anchor avg` alone. The rings depend on it and on
;; `contour avg` and on the vertex budget. The aperture depends on
;; `anchor avg` and on its own cut and NOT on `contour avg`, because
;; measure reports the inner ring's own height and nothing smooths it.
anchor-prov (prov :anchor/similarity nil)
roto (fn [by] (prov by {:verts verts :contour-avg contour-avg}))
scene {:name name
:frames nf
:fps fps
;; Stage dimensions, on the clip and not on the footage. See
;; face-placement: nothing below here knows the frame size.
:width (first stage)
:height (second stage)
:nodes
{:root
{:id :root :name "clip" :kind :group :parent nil :z "a1"
:time {:mode :map :expose expose}}
:face
{:id :face :name "face" :kind :group :parent :root :z "a1"
:channels (face-placement params inputs)}
:head
{:id :head :name "head" :kind :group :parent :face :z "a1"
:measured {[:xform :pos] (dense (head-k "pos") pos 0 anchor-prov)
[:xform :rot] (dense (head-k "rot") rot 0 anchor-prov)
[:xform :scale] (dense (head-k "scale") scale 0 anchor-prov)}}
;; The outer lip ring is the dark band OUTSIDE the interior, and
;; that three-layer structure — dark ring, pale interior, teeth
;; — is what makes a flat shape read as an opening rather than
;; as a blob. So it keeps every frame and is never hidden.
:mouth
{:id :mouth :name "mouth" :kind :poly :parent :head :z "a1"
:channels {[:geom :pts] (dense geom-k rings 0 (roto :roto/lips-outer))
[:style :color] (ch/framed :skin-dark)}}
:mouth-in
{:id :mouth-in :name "mouth interior" :kind :poly
:parent :mouth :z "a2"
:channels {[:geom :pts] (dense geom-k rings 1 (roto :roto/lips-inner))
[:style :color] (ch/framed :mouth-dark)
[:vis] (visibility params inputs
(prov :roto/mouth-aperture
{:aperture-cut aperture-cut}))}}}}
;; Tier 2, behind a handle. The keys are descriptive because there is no
;; hashing yet; they become the blocks' sha256 when the backend arrives
;; and nothing above here changes, which is the point of a handle.
store (into {} (map (fn [[k blk]] [k (select-keys blk [:data :state])]))
{geom-k rings
(head-k "pos") pos, (head-k "rot") rot, (head-k "scale") scale})]
{:store store
:scene (head-mode {:mode head :kept kept} {:scene scene :store store})}))

View file

@ -37,8 +37,9 @@
"RMS misfit per frame, in the isotropic space's units — one image height.
Taken against the transforms it is HANDED rather than against a fit of its own,
so the same function serves the stage-3 reading and the parity diff. The
prototype took it against the SMOOTHED transforms, which folds the smoothing
which is what let the parity diff assert on exactly what the prototype handed
it while that diff existed. The prototype took the residual against the
SMOOTHED transforms, which folds the smoothing
error into a number whose whole job is to say whether the footage is
stabilisable at all; `fit` takes it against the raw fit instead."
[ref rigid tfs]

View file

@ -11,3 +11,8 @@
(rf/reg-sub ::muted? (fn [db _] (get-in db [:playback :muted?])))
(rf/reg-sub ::fps (fn [db _] (get-in db [:clip :fps])))
(rf/reg-sub ::frames (fn [db _] (get-in db [:clip :frames])))
;; The stage, in pixels. On the clip because project dimensions are independent
;; of the footage — see flow/freeze/face-placement — so the canvas and the raster
;; take their size from the document rather than from a constant.
(rf/reg-sub ::width (fn [db _] (get-in db [:clip :width])))
(rf/reg-sub ::height (fn [db _] (get-in db [:clip :height])))

View file

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

View file

@ -107,6 +107,8 @@
:ramp @(rf/subscribe [::render/ramp])
:fps @(rf/subscribe [::sub/fps])
:frames @(rf/subscribe [::sub/frames])
:width @(rf/subscribe [::sub/width])
:height @(rf/subscribe [::sub/height])
:frame @(rf/subscribe [::sub/frame])
:playing? @(rf/subscribe [::sub/playing?])})
;; A new resolver means a new scene or a new palette, and neither
@ -133,13 +135,16 @@
rasterised before the next frame is asked for."
[f]
(let [{:keys [canvas]} @state
{:keys [resolver palette ramp]} @snapshot]
(when (and canvas resolver)
{:keys [resolver palette ramp width height]} @snapshot]
(when (and canvas resolver width height)
;; User Timing, so a profile in the DevTools performance panel has named
;; spans in the Timings track instead of a wall of anonymous frames. Three
;; lines, and the difference between reading a profile and guessing at one.
(js/performance.mark "arthur/paint:start")
(let [ras (raster-for 320 200)]
;; THE STAGE IS THE CLIP'S, not a constant. Project dimensions are
;; independent of the footage, so the size the frame is rasterised at comes
;; out of the document like everything else.
(let [ras (raster-for width height)]
(-> ras
(raster/clear! (get palette :bg 0))
(raster/draw-ops! (resolver f)))

View file

@ -7,7 +7,6 @@
why scrubbing at speed does not re-render the page."
(:require [arthur.clock :as clock]
[arthur.db :as db]
[arthur.demo :as demo]
[arthur.events.playback :as pb]
[arthur.subs.playback :as sub]
[arthur.subs.render :as render]
@ -80,14 +79,22 @@
(when (and drop (pos? drop))
(str " · " (.toFixed drop 2) " frames/paint")))])]]))
(defn- stage []
;; The canvas is the STAGE's size, and the stage is the clip's — not a constant
;; and not the footage's. Reactive, so selecting a clip of another size resizes
;; it; ui/canvas guards the width assignment, which reallocates the backing
;; store, so this being a re-render costs nothing per frame.
(let [w @(rf/subscribe [::sub/width])
h @(rf/subscribe [::sub/height])]
[:canvas.stage
{:ref #(player/set-canvas! %)
:width w :height h
:style {:width (str (* zoom w) "px") :height (str (* zoom h) "px")}}]))
(defn view []
[:main
[:h1 "arthur"]
[:canvas.stage
{:ref #(player/set-canvas! %)
:width demo/width :height demo/height
:style {:width (str (* zoom demo/width) "px")
:height (str (* zoom demo/height) "px")}}]
[stage]
[audio]
[transport]
[:p.note