Trace a face over its footage, and choose where its origin goes

A face's :head carries :trace {:frames :origin}: the frames its photo holds
on, and whether the head reads every frame, jumps to the trace frames, or
holds frame 0. It replaces :anchors, so which measured frame a head reads is
one stored fact. An instance's :underlay shows the tracing stills over every
face at or below it, registered through each face's own head, at an opacity,
unkeyed. The clip resolver answers where a row path went on its last frame,
so the paint loop reads the photo's matrix instead of resolving again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-30 02:49:06 -04:00
parent 550cfe91e5
commit 309c47e0a7
23 changed files with 639 additions and 164 deletions

View file

@ -90,7 +90,6 @@
(deftest a-measured-transform-is-not-set-by-hand
(is (string? (gesture/refusal {:channels {[:xform :pos] {:animated? true :dense {:stride 2}}}})))
(is (string? (gesture/refusal {:anchors {0 12}})))
(is (nil? (gesture/refusal {:channels {[:xform :pos] (ch/keyed {0 [1 1]})}}))))
(deftest a-click-selects-the-level-figma-would

View file

@ -69,7 +69,7 @@
out (js/Float64Array. 2)
seen (mapcat (fn [[x y]] (vec (array-seq (node/apply-pt! out 0 matrix x y))))
(partition 2 [0 0 10 0 5 10]))
[x y] (array-seq (node/apply-pt! out 0 (nest/invert matrix) 7 3))
[x y] (array-seq (node/apply-pt! out 0 (node/invert matrix) 7 3))
moved (paint/set-vertex c :box :shape frame 0 [x y])
keyed (paint/add-key c :box :shape (:frame (nest/inside c nil :main [u v :shape] 20)))]
(is (= 4 frame) "16 of main is 6 of mid, which is 4 of box and of the shape in it")

View file

@ -0,0 +1,114 @@
(ns arthur.domain.trace-test
(:require [cljs.test :refer [deftest is]]
[arthur.demo.take :as take]
[arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest]
[arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol]
[arthur.domain.trace :as trace]
[arthur.flow.freeze :as freeze]))
(def ^:private frozen (delay (freeze/head-mode {} @take/frozen)))
(def ^:private store (delay (:store @take/frozen)))
(defn- traced
"The synthetic take with face-1's trace set to `t`."
[t]
(assoc-in @frozen [:symbols :face-1 :nodes :head :trace] t))
(defn- head [c] (get-in c [:symbols :face-1 :nodes :head]))
(defn- near? [a b]
(every? #(< (js/Math.abs %) 1e-9) (map - a b)))
(deftest the-origin-picks-the-measured-frame-a-head-reads
(let [at (fn [t fs] (map #(trace/held-frame (trace/prepare t) %) fs))]
(is (nil? (trace/prepare {:frames [4 9] :origin :continuous})))
(is (nil? (trace/prepare nil)))
(is (= [0 0 0] (at {:frames [4 9] :origin :start} [0 5 50])))
(is (= [4 4 4 4 9 9] (at {:frames [4 9] :origin :keys} [0 3 4 8 9 50]))
"a jump at each key, and before the first the first")
(is (= [0 0] (at {:frames [] :origin :keys} [0 30])))))
(deftest every-origin-is-a-valid-document-that-saves
(doseq [origin trace/origins
:let [c (traced {:frames [0 12 40] :origin origin})]]
(is (empty? (clip/problems c)) (str origin ": " (pr-str (clip/problems c))))
(is (= c (leaf/clip "t" (leaf/leaves "t" c))) (str origin " round-trips"))))
(deftest a-trace-the-take-cannot-hold-will-not-save
(doseq [t [{:frames [0] :origin :sideways} {:frames [9 3] :origin :keys}
{:frames [9999] :origin :keys}]]
(is (seq (clip/problems (traced t))) (pr-str t))))
(deftest the-photo-holds-each-trace-frame-until-the-next
(let [t {:frames [5 20]}]
(is (= [5 5 5 5 20 20] (map #(trace/photo-frame t %) [0 4 5 19 20 90]))))
(is (= 33 (trace/photo-frame {:frames []} 33)) "no keys: every frame is its own")
(is (= [3 7] (:frames (trace/toggle-frame {:frames [7]} 3))))
(is (= [] (:frames (trace/toggle-frame {:frames [7]} 7)))))
(defn- photo-at
"The photo matrix of face-1 alone at frame `f`, the still being 1000px tall."
[c f]
(let [r (symbol/resolver (clip/symbol c :face-1) @store pal/index-of)
h (head c)]
(r f)
(vec (array-seq (trace/photo-matrix (symbol/world-of r :head) h @store
(trace/photo-frame (trace/of h) f) 1000)))))
(deftest the-photo-registers-to-the-face-it-was-filmed-with
;; Face-1 is its own symbol and its head is its root, so a photo sitting where
;; it was filmed is image pixels over image height and nothing else.
(let [filmed [0.001 0 0 0.001 0 0]]
(is (near? filmed (photo-at (traced {:frames [] :origin :continuous}) 30))
"a head showing the photo's own frame cancels out")
(is (near? filmed (photo-at (traced {:frames [12] :origin :keys}) 30))
"held at the trace frame, face and photo both stand still")
(is (not (near? filmed (photo-at (traced {:frames [12] :origin :continuous}) 30)))
"a continuous head carries the held photo along with it")))
(defn- wrapped
"Face-1's take placed, moved, inside a symbol :wrap, with `underlays` on the
instances named."
[{:keys [outer inner]}]
(-> @frozen
(assoc-in [:symbols :wrap] {:id :wrap :frames 200
:nodes {:m (cond-> {:id :m :kind :instance :of :main :z "a0"
:channels {[:xform :pos] {:animated? false
:value [30 -10]}}}
outer (assoc :underlay outer))}})
(cond-> inner (assoc-in [:symbols :main :nodes :face-1 :underlay] inner))))
(deftest an-underlay-covers-the-faces-below-it-and-the-nearest-decides
(is (= [{:path [:m :face-1] :face :face-1 :opacity 0.3}]
(trace/shown (wrapped {:outer {:on? true :opacity 0.3}}) :wrap))
"switched on at the take, its face shows")
(is (= [] (trace/shown (wrapped {:outer {:on? true} :inner {:on? false}}) :wrap))
"and the face can still be switched off inside it")
(is (= [{:path [:m :face-1] :face :face-1 :opacity 0.8}]
(trace/shown (wrapped {:inner {:on? true :opacity 0.8}}) :wrap)))
(let [c (wrapped {:outer {:on? true :opacity 0.3}})]
(is (= {:on? true :opacity 0.3 :own? false} (trace/underlay-at c :wrap [:m :face-1])))
(is (= {:on? true :opacity 0.3 :own? true} (trace/underlay-at c :wrap [:m])))
(is (nil? (trace/underlay-at @frozen :main [:face-1])))))
(deftest a-take-lists-the-faces-in-it
(is (= [{:path [:face-1] :in :main :face :face-1}] (trace/faces @frozen :main)))
(is (= [{:path [:m :face-1] :in :main :face :face-1}] (trace/faces (wrapped {}) :wrap)))
(is (= [] (trace/faces @frozen :face-1))))
(deftest the-resolver-says-where-a-nested-head-went-on-its-last-frame
;; The same answer as `nest/placement`, which walks and resolves the path all
;; over again — the resolver has it already, from drawing the frame.
(let [c (wrapped {})
r (clip/resolver c @store pal/index-of :wrap)
path [:m :face-1 :head]]
(doseq [f [0 17 60]]
(r f)
(let [pl (nest/placement c @store :wrap path f)]
(is (near? (array-seq (:world pl)) (array-seq (symbol/world-of r path))) (str f))
(is (= (:frame pl) (js/Math.floor (symbol/frame-of r path))) (str f))))
(r 500)
(is (nil? (symbol/world-of r path)) "not on the frame, not anywhere")))

View file

@ -78,12 +78,10 @@
;; before the data is trusted — which is what it is for. It checks the clip's
;; fields, every timeline in it and the tracking identities, so it is the whole
;; of what a save would refuse.
(doseq [spec [{:mode :free} {:mode :anchored :anchors {0 0}}]]
(doseq [spec [{} {:trace {:origin :start}} {:trace {:origin :continuous :frames [4]}}
{:trace {:origin :keys :frames [12 88 150]}}]]
(let [c (freeze/head-mode spec @frozen)]
(is (empty? (clip/problems c)) (str spec ": " (pr-str (clip/problems c))))))
(let [c (freeze/head-mode {:mode :anchored
:anchors {0 12, 40 88, 150 150}} @frozen)]
(is (empty? (clip/problems c)) (pr-str (clip/problems c)))))
(is (empty? (clip/problems c)) (str spec ": " (pr-str (clip/problems c)))))))
(deftest the-tree-is-the-one-the-model-specifies
(is (= [:face :root] (symbol/lineage (:nodes (clip/symbol @clip* :main)) :face)))
@ -194,7 +192,7 @@
;; of the two says it is. A test that recomputed the chain would only be
;; checking arithmetic against itself; this checks `node/local!`, `node/world!`
;; and `emit` as well.
(let [c (freeze/head-mode {:mode :free} @frozen)
(let [c (freeze/head-mode {} @frozen)
res (clip/resolver c @store pal/index-of :main)
k (first (:value (chan :face [:xform :scale])))
anc (:value (chan :face [:xform :anchor]))
@ -232,48 +230,48 @@
"], the composition says [" wx " " wy "]"))))))
;; ---------------------------------------------------------------------------
;; one dense measurement, with optional held anchor frames
;; one dense measurement, with optional held trace frames
(deftest head-anchors-select-measured-frames-without-copying-channels
(let [free (freeze/head-mode {:mode :free} @frozen)
one (freeze/head-mode {:mode :anchored :anchors {0 12}} @frozen)
keyed (freeze/head-mode {:mode :anchored
:anchors {0 12, 40 88, 150 150}} @frozen)
(deftest a-head-trace-selects-measured-frames-without-copying-channels
(let [free (freeze/head-mode {} @frozen)
one (freeze/head-mode {:trace {:origin :keys :frames [12]}} @frozen)
keyed (freeze/head-mode {:trace {:origin :keys :frames [12 88 150]}} @frozen)
of (fn [c path] (get-in (nodes c) [:head :channels path]))]
(doseq [c [free one keyed]
path [[:xform :pos] [:xform :rot] [:xform :scale]]]
(is (= :dense (ch/describe (of c path))))
(is (= (of free path) (of c path)) "anchor edits do not copy measurements"))
(is (nil? (get-in (nodes free) [:head :anchors])))
(is (= {0 12} (get-in (nodes one) [:head :anchors])))
(is (= {0 12, 40 88, 150 150}
(get-in (nodes keyed) [:head :anchors])))
(is (= (of free path) (of c path)) "a trace does not copy measurements"))
(is (nil? (get-in (nodes free) [:head :trace])))
(is (= {:frames [12] :origin :keys} (get-in (nodes one) [:head :trace])))
(is (= keyed (leaf/clip "head" (leaf/leaves "head" keyed)))
"anchor source addresses survive the document round trip")))
"the trace survives the document round trip")))
(deftest head-anchor-keys-hold-the-whole-measured-transform
(let [free (symbol/resolver (face-symbol
(freeze/head-mode {:mode :free} @frozen))
@store pal/index-of)
(deftest trace-keys-hold-the-whole-measured-transform
(let [free (symbol/resolver (face-symbol (freeze/head-mode {} @frozen))
@store pal/index-of)
held (symbol/resolver (face-symbol
(freeze/head-mode {:mode :anchored
:anchors {0 12, 40 88}} @frozen))
@store pal/index-of)
(freeze/head-mode {:trace {:origin :keys :frames [12 88]}} @frozen))
@store pal/index-of)
start (symbol/resolver (face-symbol
(freeze/head-mode {:trace {:origin :start :frames [12 88]}} @frozen))
@store pal/index-of)
world (fn [resolver frame]
(resolver frame)
(vec (array-seq (symbol/world-of resolver :head))))]
(is (= (world free 12) (world held 0)))
(is (= (world free 12) (world held 38)))
(is (= (world free 88) (world held 40)))
(is (= (world free 88) (world held 100)))))
(is (= (world free 12) (world held 0)) "before the first key, the first holds")
(is (= (world free 12) (world held 87)))
(is (= (world free 88) (world held 88)) "a jump, not a tween")
(is (= (world free 88) (world held 100)))
(is (not= (world free 0) (world free 100)) "the head does move, so the above says something")
(is (= (world free 0) (world start 50) (world start 100)))))
(deftest switching-modes-rewrites-the-head-and-nothing-else
;; It has to be impossible for the toggle to move something a hand placed, and
;; it has to be a DOCUMENT edit: tier 1, undoable, syncable, instant, and not a
;; reason to re-analyse.
(let [a (freeze/head-mode {:mode :free} @frozen)
b (freeze/head-mode {:mode :anchored :anchors {0 0}} @frozen)
c (freeze/head-mode {:mode :anchored :anchors {0 0, 40 40}} @frozen)]
(let [a (freeze/head-mode {} @frozen)
b (freeze/head-mode {:trace {:origin :start}} @frozen)
c (freeze/head-mode {:trace {:origin :keys :frames [0 40]}} @frozen)]
(doseq [x [b c]]
(is (= (get (nodes a) :face) (get (nodes x) :face))
":face moved")
@ -287,14 +285,17 @@
;; channels, so the clip's own fields and its other timelines are untouched.
(is (= (dissoc a :symbols) (dissoc x :symbols))))))
(deftest invalid-head-anchor-maps-are-refused
(is (thrown-with-msg? ExceptionInfo #"free or anchored"
(freeze/head-mode {:mode :stabilised} @frozen)))
(doseq [anchors [nil {} {12 12} {0 take/frames} {0 0, 10 -1}]]
(is (thrown-with-msg? ExceptionInfo #"frame-zero key"
(freeze/head-mode {:mode :anchored :anchors anchors} @frozen))))
(is (thrown-with-msg? ExceptionInfo #"has no anchors"
(freeze/head-mode {:mode :free :anchors {0 0}} @frozen))))
(deftest invalid-head-traces-are-refused
(doseq [t [{} {:origin :stabilised} {:origin :keys :frames [take/frames]}
{:origin :keys :frames [-1]} {:origin :keys :frames [12 4]}
{:origin :keys :frames [4 4]} {:origin :keys :frames '(4)}]]
(is (thrown-with-msg? ExceptionInfo #"trace is not one this take can hold"
(freeze/head-mode {:trace t} @frozen))
(pr-str t)))
(is (seq (clip/problems (assoc-in (freeze/head-mode {} @frozen)
[:symbols :face-1 :nodes :head :trace]
{:origin :keys :frames [take/frames]})))
"and a document holding one will not save"))
;; ---------------------------------------------------------------------------
;; the face: authored, and what makes makeXform deletable
@ -598,7 +599,7 @@
;; from different beats are genuinely different mouths and frames inside one
;; beat are not. This is the numeric half of step 5's done-criterion; the other
;; half is a picture and lives in test/browser/take.mjs.
(let [locked (freeze/head-mode {:mode :anchored :anchors {0 0}} @frozen)
(let [locked (freeze/head-mode {:trace {:origin :start}} @frozen)
shot (fn [f]
(let [r (raster/make W H)
mouth (filter #(= [:face-1 :mouth] (:node %))
@ -616,6 +617,6 @@
(is (< (differ (shot 10) (shot 12)) 200)
"a held pose is moving more than the detector noise it should have lost"))
;; As filmed, the head carries it around the stage as well.
(let [filmed (freeze/head-mode {:mode :free} @frozen)]
(let [filmed (freeze/head-mode {} @frozen)]
(is (> (count (remove true? (map = (render filmed 10) (render filmed 120)))) 300)
"the head does not move across the take")))

View file

@ -20,7 +20,7 @@
(def frames 40)
(def settings
(merge take/knobs {:name "two faces" :fps 30 :aspect 1 :stage [320 200]
:fit-motion? true :expose 1 :head :anchored :anchors {0 0}
:fit-motion? true :expose 1 :trace {:origin :start}
:analysis (address/analysis {:detector "synth" :version "two-faces-v1"
:seed 9 :frames frames :fps 30 :aspect 1})}))
(def inputs
@ -62,13 +62,13 @@
(assoc-in clip [:features :duplicate]
(assoc (get-in clip [:features :face-2/mouth]) :id :duplicate)))))))
(deftest one-subjects-edit-and-anchor-do-not-change-its-neighbor
(deftest one-subjects-edit-and-trace-do-not-change-its-neighbor
(let [before @initial
at [:clip :symbols :face-2 :nodes :iris-r :channels [:style :color]]
before (assoc-in before at (ch/framed :brow))
after (regenerate/change before {:scope :feature :id :face-2/eye-r
:knob :gaze-gain :value 2})
anchored (freeze/head-mode {:subject :face-2 :mode :anchored :anchors {0 12}} after)]
anchored (freeze/head-mode {:subject :face-2 :trace {:origin :keys :frames [12]}} after)]
(is (= (get-in before at) (get-in after at)) "authored channels survive regeneration")
(is (= (get-in before [:clip :symbols :face-1])
(get-in after [:clip :symbols :face-1])
@ -77,7 +77,7 @@
(channel after :face-2 :iris-r [:xform :pos])))
(is (= (channel before :face-2 :iris-l [:xform :pos])
(channel after :face-2 :iris-l [:xform :pos])))
(is (= {0 12} (get-in anchored [:symbols :face-2 :nodes :head :anchors])))
(is (= {:origin :keys :frames [12]} (get-in anchored [:symbols :face-2 :nodes :head :trace])))
(is (empty? (clip/problems anchored)))))
(deftest the-second-subject-regenerates-inside-a-composed-stage
@ -92,9 +92,9 @@
(get-in after [:clip :symbols sid]))))
(is (empty? (clip/problems (:clip after))))
(is (thrown? ExceptionInfo
(freeze/head-mode {:subject :face-2 :mode :anchored :anchors {0 frames}}
(freeze/head-mode {:subject :face-2 :trace {:origin :keys :frames [frames]}}
after))
"anchors are bounded by the source timeline, even on a longer stage")))
"trace frames are bounded by the source timeline, even on a longer stage")))
(deftest an-instance-pose-cut-only-holds-that-faces-mouth
(let [{:keys [clip store]} @initial
@ -114,7 +114,7 @@
(assoc-in [:face-2 :presence :eye-r]
(assoc (vec (repeat frames true)) 12 false)))
{:keys [clip store]} (take/build settings inputs)
clip (freeze/head-mode {:mode :free} {:clip clip})
clip (freeze/head-mode {} {:clip clip})
at8 (by-node clip store 8)
at12 (by-node clip store 12)]
(is (some? (get at8 [:face-1 :mouth])))