Split clips from timelines

This commit is contained in:
Olive Vaughn 2026-09-28 02:33:26 -04:00
parent 27bfe18bee
commit 9778b9023b
31 changed files with 794 additions and 444 deletions

View file

@ -10,9 +10,10 @@
here would fail on a loaded CI box and teach everyone to ignore it."
(:require [cljs.test :refer [deftest is]]
[arthur.demo.swarm :as swarm]
[arthur.domain.clip :as clip]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.scene :as scene]))
[arthur.domain.timeline :as timeline]))
(defn- ms [label n f]
(let [t0 (js/Date.now)]
@ -23,11 +24,11 @@
(/ dt n))))
(deftest bench
(let [res (scene/resolver @swarm/scene @swarm/store pal/index-of)
(let [res (timeline/resolver (clip/root @swarm/clip) @swarm/store pal/index-of)
ras (raster/make 320 200)
dest (js/Uint8ClampedArray. (* 320 200 4))
n 120]
(println "\nswarm:" (count (:nodes @swarm/scene)) "nodes")
(println "\nswarm:" (count (clip/nodes @swarm/clip)) "nodes")
(let [a (ms "resolve " n (fn [i] (res (mod i 229))))
b (ms "resolve+draw " n (fn [i]
(raster/clear! ras 0)

View file

@ -4,74 +4,97 @@
subtly smaller, and the loss is discovered later, by somebody whose work is
already gone.
So the assertion is exact equality on the real scenes — the frozen take in both
So the assertion is exact equality on the real clips — the frozen take in both
head modes, the hand-written demo, the swarm — rather than on a fixture, and
`scene-keys` makes a field added without a leaf fail loudly instead."
`clip/clip-keys` plus `timeline/timeline-keys` make a field added without a leaf
fail loudly instead."
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo :as demo]
[arthur.demo.swarm :as swarm]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf]))
(deftest every-real-scene-survives-the-split-exactly
(doseq [[label scene] [["the frozen take" @take/scene]
["the locked take" @take/locked]
["the hand-written demo" demo/scene]
["the swarm" @swarm/scene]]]
(defn- one-timeline
"A minimal clip holding one timeline of these nodes, for the cases that are
about a path rather than about a take."
[nodes]
{:timelines {:main {:id :main :frames 1 :nodes nodes}}})
(deftest every-real-clip-survives-the-split-exactly
(doseq [[label c] [["the frozen take" @take/clip]
["the locked take" @take/locked]
["the hand-written demo" demo/clip]
["the swarm" @swarm/clip]]]
(testing label
(is (= scene (leaf/scene :c1 (leaf/leaves :c1 scene)))))))
(is (= c (leaf/clip :c1 (leaf/leaves :c1 c)))))))
(deftest the-leaves-are-the-paths-the-sync-design-names
(let [ls (leaf/leaves :c7 @take/scene)]
(let [ls (leaf/leaves :c7 @take/clip)]
(is (contains? ls "clip/c7/timing"))
(is (contains? ls "clip/c7/stage"))
(is (contains? ls "clip/c7/source"))
(is (contains? ls "clip/c7/node/mouth"))
(is (contains? ls "clip/c7/channel/mouth/geom.pts"))
(is (contains? ls "clip/c7/channel/mouth-in/vis"))
;; A TIMELINE ID IS A SEGMENT, which is what lets a symbol's nodes be
;; addressed by the same path shape as the clip's own. `main` is the root.
(is (contains? ls "clip/c7/timeline/main"))
(is (= {:frames 229} (get ls "clip/c7/timeline/main"))
"a timeline's leaf is its frame space; :fps is the clip's")
(is (contains? ls "clip/c7/timeline/main/node/mouth"))
(is (contains? ls "clip/c7/timeline/main/channel/mouth/geom.pts"))
(is (contains? ls "clip/c7/timeline/main/channel/mouth-in/vis"))
(is (contains? ls "clip/c7/feature/eye-r"))
(is (contains? ls "clip/c7/group/eyes-1"))
(is (contains? ls "clip/c7/subject/face-1"))
;; `:head`'s measured channels are written together by a freeze and replaced
;; together by a re-freeze, so they are one leaf and not three.
(is (contains? ls "clip/c7/measured/head"))
(is (= 3 (count (get ls "clip/c7/measured/head"))))))
(is (contains? ls "clip/c7/timeline/main/measured/head"))
(is (= 3 (count (get ls "clip/c7/timeline/main/measured/head"))))
;; :frames is NOT in `timing` any more. A timeline is a frame space and a clip
;; is a rate, so the one leaf that held both was the persistence half of the
;; conflation `domain/clip` exists to undo.
(is (= {:fps 30} (get ls "clip/c7/timing")))))
(deftest a-node-and-its-channels-are-different-leaves
;; The boundary that lets two people key different parts without meeting. A node
;; leaf carries structure and no geometry.
(let [ls (leaf/leaves :c1 @take/scene)
n (get ls "clip/c1/node/mouth")]
(let [ls (leaf/leaves :c1 @take/clip)
n (get ls "clip/c1/timeline/main/node/mouth")]
(is (= {:id :mouth :name "mouth" :kind :poly :parent :head :z "a1"} n))
(is (nil? (:channels n)))
(is (:animated? (get ls "clip/c1/channel/mouth/geom.pts")))))
(is (:animated? (get ls "clip/c1/timeline/main/channel/mouth/geom.pts")))))
(deftest a-field-with-no-leaf-is-refused-rather-than-dropped
;; The invariant that keeps the round trip exact as the model grows: a scene
;; field nobody gave a leaf to would save silently and come back missing.
;; The invariant that keeps the round trip exact as the model grows: a field
;; nobody gave a leaf to would save silently and come back missing. Asserted at
;; BOTH levels now, because there are two types that can grow one.
(is (thrown-with-msg? ExceptionInfo #"no leaf to save it in"
(leaf/leaves :c1 (assoc @take/scene :sequences []))))
(is (= leaf/scene-keys (set (keys (assoc @take/scene :name "x"))))
"scene-keys has drifted from what a frozen scene actually holds"))
(leaf/leaves :c1 (assoc @take/clip :sequences []))))
(is (thrown-with-msg? ExceptionInfo #"no leaf to save it in"
(leaf/leaves :c1 (assoc-in @take/clip
[:timelines :main :markers] []))))
(is (= clip/clip-keys (set (keys (assoc @take/clip :name "x"))))
"clip-keys has drifted from what a frozen clip actually holds"))
(deftest an-absent-field-stays-absent
;; A scene with no fps must not come back with `:fps nil`. `=` is the test, and
;; the demo scene is the case: it has no analysis record and its root has no
;; A clip with no analysis must not come back with `:analysis nil`. `=` is the
;; test, and the demo is the case: no analysis record, and its root node has no
;; channels.
(let [ls (leaf/leaves :c1 demo/scene)]
(let [ls (leaf/leaves :c1 demo/clip)]
(is (not (contains? ls "clip/c1/source")))
(is (not (contains? (leaf/scene :c1 ls) :analysis)))
(is (not (contains? (get-in (leaf/scene :c1 ls) [:nodes :root]) :channels)))))
(is (not (contains? (leaf/clip :c1 ls) :analysis)))
(is (not (contains? (get-in (leaf/clip :c1 ls)
[:timelines :main :nodes :root])
:channels)))))
(deftest a-namespaced-id-is-one-path-segment
;; docs/architecture.md draws a node as `:eye-r/iris`, and a leaf path is
;; "/"-delimited, so the two have to be reconciled somewhere.
(let [scene {:nodes {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1"
:channels {[:geom :radius] (ch/framed 2)}}}}
ls (leaf/leaves :c1 scene)]
(is (contains? ls "clip/c1/node/eye-r~iris"))
(is (= scene (leaf/scene :c1 ls))))
(let [c (one-timeline {:eye-r/iris {:id :eye-r/iris :kind :disc :parent nil :z "a1"
:channels {[:geom :radius] (ch/framed 2)}}})
ls (leaf/leaves :c1 c)]
(is (contains? ls "clip/c1/timeline/main/node/eye-r~iris"))
(is (= c (leaf/clip :c1 ls))))
;; `(keyword "a~b")` rather than a literal: ~ is unquote in CLJS source.
(is (thrown-with-msg? ExceptionInfo #"cannot contain ~"
(leaf/segment (keyword "a~b")))))
@ -79,10 +102,10 @@
(deftest another-clips-leaves-are-ignored-rather-than-merged
;; A project's whole leaf map can be handed in for one clip, which is what makes
;; a two-clip project one fetch.
(let [a (leaf/leaves :a @take/scene)
b (leaf/leaves :b demo/scene)]
(is (= @take/scene (leaf/scene :a (merge a b))))
(is (= demo/scene (leaf/scene :b (merge a b))))))
(let [a (leaf/leaves :a @take/clip)
b (leaf/leaves :b demo/clip)]
(is (= @take/clip (leaf/clip :a (merge a b))))
(is (= demo/clip (leaf/clip :b (merge a b))))))
;; ---------------------------------------------------------------------------
;; what a document may not contain
@ -91,20 +114,33 @@
;; `demo/swarm` names its blocks "swarm/pos", which is exactly the descriptive
;; key content addressing replaced: a handle that only means something on the
;; machine that made it. It is a fine load test and not a document.
(let [ps (leaf/problems (leaf/leaves :c1 @swarm/scene))]
(let [ps (leaf/problems (leaf/leaves :c1 @swarm/clip))]
(is (seq ps))
(is (some #(re-find #"names tier 2 as \"swarm/pos\"" %) ps) (pr-str (first ps)))))
(deftest a-frozen-clip-has-no-problems
(is (empty? (leaf/problems (leaf/leaves :c1 @take/scene))))
(is (empty? (leaf/problems (leaf/leaves :c1 @take/clip))))
(is (empty? (leaf/problems (leaf/leaves :c1 @take/locked)))))
(deftest a-channel-leaf-for-a-node-that-is-not-there-is-named
(let [ls (dissoc (leaf/leaves :c1 @take/scene) "clip/c1/node/mouth")]
(let [ls (dissoc (leaf/leaves :c1 @take/clip) "clip/c1/timeline/main/node/mouth")]
(is (some #(re-find #"node with no node leaf" %) (leaf/problems ls)))))
(deftest a-node-leaf-is-scoped-to-its-own-timeline
;; The reason the node index in `problems` is keyed by (clip, timeline, node)
;; rather than by node alone: two timelines may each hold a `:mouth`, and a
;; channel of one is not a channel of the other. Keyed by node alone, deleting
;; the root's node leaf would have been excused by the symbol's.
(let [ls (-> (leaf/leaves :c1 @take/clip)
(assoc "clip/c1/timeline/sym~blink" {:frames 3}
"clip/c1/timeline/sym~blink/node/mouth"
{:id :mouth :kind :poly :parent nil :z "a1"})
(dissoc "clip/c1/timeline/main/node/mouth"))]
(is (some #(re-find #"node with no node leaf" %) (leaf/problems ls)))))
(deftest a-property-with-path-punctuation-in-it-is-refused
(is (thrown-with-msg?
ExceptionInfo #"cannot contain . or /"
(leaf/leaves :c1 {:nodes {:a {:id :a :kind :poly :parent nil :z "a1"
:channels {[:geom :pts.x] (ch/framed [0 0])}}}}))))
(leaf/leaves :c1 (one-timeline
{:a {:id :a :kind :poly :parent nil :z "a1"
:channels {[:geom :pts.x] (ch/framed [0 0])}}})))))

View file

@ -9,9 +9,9 @@
back as a slightly wrong performance rather than as an error.
So what is compared is the OPS, frame for frame, through both evaluators, in
every frame order — the same machinery scene-test uses to hold `eval-frame` and
`resolver` to each other, which is the strictest statement available about two
scenes being the same scene.
every frame order — the same machinery timeline-test uses to hold `eval-frame`
and `resolver` to each other, which is the strictest statement available about
two documents being the same document.
This runs the conversion the network runs — `JSON.parse(JSON.stringify(...))` —
and not the network. `clips/tests.py` puts the same document through Django, and
@ -20,8 +20,9 @@
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.project :as project]
[arthur.domain.scene :as scene]
[arthur.domain.timeline :as timeline]
[arthur.flow.freeze :as freeze]
[arthur.support.ops :as ops]))
@ -33,28 +34,31 @@
(def ^:private before (delay @take/frozen))
(def ^:private after (delay (wired :c1 @before)))
(deftest what-comes-back-is-a-valid-scene
(let [ps (scene/problems (:scene @after))]
(deftest what-comes-back-is-a-valid-clip
;; `clip/problems` and not `timeline/problems`: the round trip has to preserve
;; the tracking identities and the timeline map as well as the nodes, and only
;; the clip-level check looks at those.
(let [ps (clip/problems (:clip @after))]
(is (empty? ps) (pr-str ps))))
(deftest the-document-comes-back-equal
;; Stronger than it needs to be and worth having: not merely equivalent, EQUAL.
;; Any drift here is a field the codec is rewriting, and a field that is
;; rewritten once is rewritten again on every save.
(is (= (:scene @before) (:scene @after))))
(is (= (:clip @before) (:clip @after))))
(deftest every-frame-resolves-to-the-same-ops-before-and-after
;; The assertion. Both evaluators, both scenes, every frame order — so a block
;; that came back with its offsets shifted, or a cursor that seeks differently
;; over a rebuilt key map, has nowhere to hide.
(let [n (:frames (:scene @before))
(let [n (clip/frames (:clip @before))
paths {"specification" [ops/specified ops/specified]
"playback" [ops/resolved ops/resolved]
"spec vs playback, after" [ops/specified ops/resolved]}]
(doseq [[label [f g]] paths
[order fs] (ops/orders n)]
(let [a (f (:scene @before) (:store @before))
b (g (:scene @after) (:store @after))]
(let [a (f (clip/root (:clip @before)) (:store @before))
b (g (clip/root (:clip @after)) (:store @after))]
(testing (str label ", " order)
(doseq [frame fs]
(is (= (a frame) (b frame))
@ -76,11 +80,11 @@
;; The other head mode, because it is the one whose `:head` channels are FRAMED
;; rather than dense: a codec that only handled dense channels would pass
;; everything above and lose the locked take's identity transform.
(let [locked {:scene @take/locked :store @take/store}
(let [locked {:clip @take/locked :store @take/store}
back (wired :c1 locked)
a (ops/resolved (:scene locked) (:store locked))
b (ops/resolved (:scene back) (:store back))]
(is (= (:scene locked) (:scene back)))
a (ops/resolved (clip/root (:clip locked)) (:store locked))
b (ops/resolved (clip/root (:clip back)) (:store back))]
(is (= (:clip locked) (:clip back)))
(doseq [frame (range 0 take/frames 7)]
(is (= (a frame) (b frame)) (str "frame " frame)))))
@ -109,9 +113,9 @@
(deftest an-absence-mask-survives-the-wire
(let [back (wired :c1 @gappy)
at (fn [clip id path f]
(ch/value-at (get-in (:scene clip) [:nodes id :channels path])
f (:store clip)))
at (fn [entry id path f]
(ch/value-at (get-in (clip/nodes (:clip entry)) [id :channels path])
f (:store entry)))
;; Every dense track of the eye, iris, brow and brow-position blocks, and
;; the feature whose gap it must follow — the same table
;; `each-dense-track-follows-its-own-features-presence` pins.
@ -140,7 +144,8 @@
;; Presence is not visibility, on the far side too: the node is dropped from the
;; frame rather than hidden, and its partner is not.
(let [back (wired :c1 @gappy)
drawn (into #{} (map :node) ((scene/resolver (:scene back) (:store back)) 12))]
drawn (into #{} (map :node)
((timeline/resolver (clip/root (:clip back)) (:store back)) 12))]
(is (not (contains? drawn :eye-r)))
(is (contains? drawn :eye-l))
(is (contains? drawn :mouth))))

View file

@ -1,4 +1,4 @@
(ns arthur.domain.scene-test
(ns arthur.domain.timeline-test
"Frame evaluation, and the hand-written scene.
port-plan step 2 exists to find out whether the data model works BEFORE nine
@ -13,7 +13,8 @@
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.scene :as scene]
[arthur.domain.clip :as clip]
[arthur.domain.timeline :as timeline]
[arthur.support.ops :as ops]))
(defn- poly [id parent z pts color & [extra]]
@ -26,7 +27,7 @@
{:nodes (into {} (map (juxt :id identity)) nodes)})
(defn- ids-at [scene f]
(mapv :node (scene/eval-frame scene f)))
(mapv :node (timeline/eval-frame scene f)))
(def ^:private pts-of ops/points)
@ -37,9 +38,9 @@
{:id :b :kind :group :parent :a :z "a1"}
{:id :c :kind :group :parent :b :z "a1"}
{:id :d :kind :group :parent :a :z "a2"})
ord (scene/order (:nodes s))]
(is (= 0 (scene/depth (:nodes s) :a)))
(is (= 2 (scene/depth (:nodes s) :c)))
ord (timeline/order (:nodes s))]
(is (= 0 (timeline/depth (:nodes s) :a)))
(is (= 2 (timeline/depth (:nodes s) :c)))
(let [pos (into {} (map-indexed (fn [i id] [id i])) ord)]
(doseq [[id p] [[:b :a] [:c :b] [:d :a]]]
(is (< (get pos p) (get pos id)) (str p " must come before " id))))))
@ -49,12 +50,12 @@
;; diagnostic than a stack trace naming the nodes.
(let [s (sc {:id :a :kind :group :parent :b :z "a1"}
{:id :b :kind :group :parent :a :z "a1"})]
(is (thrown-with-msg? ExceptionInfo #"cycle" (scene/order (:nodes s))))
(is (seq (scene/problems s)))))
(is (thrown-with-msg? ExceptionInfo #"cycle" (timeline/order (:nodes s))))
(is (seq (timeline/problems s)))))
(deftest a-missing-parent-is-named-rather-than-silently-orphaning
(let [s (sc {:id :a :kind :group :parent :nope :z "a1"})]
(is (seq (scene/problems s)))))
(is (seq (timeline/problems s)))))
(deftest reparenting-is-one-field-and-does-not-move-a-subtree
;; The flat-with-pointers claim, asserted as the thing it buys: a reparent is an
@ -69,9 +70,9 @@
(is (identical? (get-in s [:nodes :b]) (get-in s' [:nodes :b]))
"and so is the new one")
(is (= [[0 0] [10 0] [10 10]]
(pts-of (first (filter #(= :c (:node %)) (scene/eval-frame s 0))))))
(pts-of (first (filter #(= :c (:node %)) (timeline/eval-frame s 0))))))
(is (= [[100 0] [110 0] [110 10]]
(pts-of (first (filter #(= :c (:node %)) (scene/eval-frame s' 0))))))))
(pts-of (first (filter #(= :c (:node %)) (timeline/eval-frame s' 0))))))))
;; ---- draw order ----
@ -113,7 +114,7 @@
:channels {[:xform :pos] (ch/framed [100 50])
[:xform :scale] (ch/framed [2 2])}}
(poly :p :g "a1" [0 0 10 0 10 10 0 10] :skin-base))
op (first (scene/eval-frame s 0))]
op (first (timeline/eval-frame s 0))]
(is (= [[100 50] [120 50] [120 70] [100 70]] (pts-of op)))))
(deftest a-keyed-group-position-moves-its-children-and-holds-between-keys
@ -123,7 +124,7 @@
:channels {[:xform :pos]
(ch/keyed {0 [0 0], 4 [10 0], 8 [10 10], 12 [0 10]})}}
(poly :p :g "a1" [0 0 2 0 2 2] :skin-base))
at #(first (pts-of (first (scene/eval-frame s %))))]
at #(first (pts-of (first (timeline/eval-frame s %))))]
(is (= [0 0] (at 0)))
(is (= [0 0] (at 3)) "held")
(is (= [10 0] (at 4)))
@ -140,7 +141,7 @@
{:id :g :kind :group :parent :root :z "a1"
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (scene/eval-frame s %)))))]
x-at #(first (first (pts-of (first (timeline/eval-frame s %)))))]
(is (= [0 0 0 3 3 3 6 6 6 9 9 9] (mapv x-at (range 12)))))
(testing "and a node may set its own grid, which the model permits deliberately"
@ -148,7 +149,7 @@
{:id :g :kind :group :parent :root :z "a1" :time {:mode :map :expose 4}
:channels {[:xform :pos] (ch/keyed (into {} (map (juxt identity #(vector % 0))) (range 12)))}}
(poly :p :g "a1" [0 0 1 0 1 1] :skin-base))
x-at #(first (first (pts-of (first (scene/eval-frame s %)))))]
x-at #(first (first (pts-of (first (timeline/eval-frame s %)))))]
(is (= [0 0 0 0 4 4 4 4 8 8 8 8] (mapv x-at (range 12)))))))
(deftest offset-is-per-node-which-is-the-entire-point-of-mouth-lead
@ -162,7 +163,7 @@
{:id :mouth :kind :group :parent :root :z "a2" :time {:mode :map :offset 2}
:channels {[:xform :pos] (ch/keyed keys)}}
(poly :mouth-p :mouth "a1" [0 0 1 0 1 1] :mouth-dark))
x-of (fn [f id] (->> (scene/eval-frame s f)
x-of (fn [f id] (->> (timeline/eval-frame s f)
(filter #(= id (:node %))) first pts-of first first))]
(is (= [0 1 2 3] (mapv #(x-of % :plate-p) (range 4))))
(is (= [2 3 4 5] (mapv #(x-of % :mouth-p) (range 4))) "the mouth reads ahead")))
@ -205,11 +206,11 @@
:dense {:store "pts" :offset 0 :stride 6 :frames 2}}
[:style :color] (ch/framed :mouth-dark)}}
(poly :teeth :m "a2" [0 0 1 0 1 1] :teeth))]
(is (= [:child] (mapv :node (scene/eval-frame absent-pos 0 store))))
(is (= [] (mapv :node (scene/eval-frame absent-pos 1 store)))
(is (= [:child] (mapv :node (timeline/eval-frame absent-pos 0 store))))
(is (= [] (mapv :node (timeline/eval-frame absent-pos 1 store)))
"an absent transform gives the children nowhere to be")
(is (= [:m :teeth] (mapv :node (scene/eval-frame absent-pts 0 store))))
(is (= [:teeth] (mapv :node (scene/eval-frame absent-pts 1 store)))
(is (= [:m :teeth] (mapv :node (timeline/eval-frame absent-pts 0 store))))
(is (= [:teeth] (mapv :node (timeline/eval-frame absent-pts 1 store)))
"an absent outline removes only itself")))
;; ---- stencils ----
@ -223,7 +224,7 @@
{:id :iris :kind :disc :parent :root :stencil :sclera :z "a2"
:channels {[:geom :radius] (ch/framed 4)
[:style :color] (ch/framed :iris)}})
ops (scene/eval-frame s 0)]
ops (timeline/eval-frame s 0)]
(is (= [:sclera :iris] (mapv :node ops)))
(is (= (:eye-white pal/index-of) (:stencil (second ops))))))
@ -250,7 +251,7 @@
:channels {[:geom :radius] (ch/framed 3) [:style :color] (ch/framed :iris)}}
{:id :r :kind :rect :parent :g :z "a2"
:channels {[:geom :size] (ch/framed 1.7) [:style :color] (ch/framed :pupil)}})
[d r] (scene/eval-frame s 0)]
[d r] (timeline/eval-frame s 0)]
(is (= [50 60 6] [(:cx d) (:cy d) (:r d)]))
;; 1.7 x 2 is 3.4, and a block 3.4px wide would be 3px on one frame and 4 on
;; the next, which reads as the pupil breathing.
@ -266,7 +267,7 @@
;; The frame orders and the snapshot live in `arthur.support.ops`, because the
;; same comparison is what proves a scene survived the server — see
;; flow/project-test.
(let [s demo/scene
(let [s demo/timeline
spec (ops/specified s nil)
fast (ops/resolved s nil)]
(doseq [[label fs] (ops/orders (:frames s))]
@ -277,28 +278,38 @@
(deftest the-resolver-reuses-one-buffer-per-node
;; At 30fps per-frame allocation is the only thing that will make this stutter,
;; and fixed topology is what makes the buffer size knowable at all.
(let [res (scene/resolver demo/scene)
(let [res (timeline/resolver demo/timeline)
buf-of (fn [f id] (->> (res f) (filter #(= id (:node %))) first :pts))]
(is (identical? (buf-of 0 :card) (buf-of 30 :card)))))
;; ---- the hand-written scene, end to end ----
(deftest the-hand-written-scene-is-valid
(let [ps (scene/problems demo/scene)]
(deftest the-hand-written-clip-is-valid
;; `clip/problems` rather than `timeline/problems`: it checks the clip's fields,
;; the timeline map and the tracking identities as well as the nodes, so it is
;; the check a save would make.
(let [ps (clip/problems demo/clip)]
(is (empty? ps) (pr-str ps)))
(is (pos? (:frames demo/scene))))
(is (pos? demo/frames))
(testing "a clip is not a timeline, and handing one over fails loudly"
;; The mistake this split makes easy: both are maps with an :id, and the wrong
;; one resolves to no ops rather than to an error.
(is (thrown-with-msg? ExceptionInfo #"not a timeline"
(timeline/resolver demo/clip)))
(is (thrown-with-msg? ExceptionInfo #"not a timeline"
(timeline/eval-frame demo/clip 0)))))
(deftest the-hand-written-scene-renders-and-moves
(deftest the-hand-written-clip-renders-and-moves
;; port-plan step 2's done condition, as an assertion rather than a look: the
;; scene rasterises, it writes only palette indices, and the pixels are not the
;; same on every frame.
(let [res (scene/resolver demo/scene)
(let [res (timeline/resolver demo/timeline)
render (fn [f]
(let [r (raster/make (:width demo/scene) (:height demo/scene))]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
r))
frames (mapv render (range 0 (:frames demo/scene) 6))
frames (mapv render (range 0 demo/frames 6))
sig (fn [r] (vec (array-seq (:buf r))))]
(is (every? (fn [r] (every? #(< % (count pal/rgb)) (array-seq (:buf r)))) frames)
"every byte written is a real palette index")
@ -306,17 +317,17 @@
(testing "the mark actually covers pixels"
(is (pos? (count (remove zero? (sig (first frames)))))))))
(deftest the-hand-written-scene-steps-on-the-exposure-grid
(deftest the-hand-written-clip-steps-on-the-exposure-grid
;; Exposure 2 on the clip root, inherited, so odd frames are identical to the
;; even frame before them. If this fails, exposure is being applied somewhere
;; other than the frame the channels are sampled at.
(let [res (scene/resolver demo/scene)
(let [res (timeline/resolver demo/timeline)
render (fn [f]
(let [r (raster/make (:width demo/scene) (:height demo/scene))]
(let [r (raster/make (:width demo/clip) (:height demo/clip))]
(raster/clear! r (:bg pal/index-of))
(raster/draw-ops! r (res f))
(vec (array-seq (:buf r)))))]
(doseq [f (range 0 (:frames demo/scene) 2)]
(doseq [f (range 0 demo/frames 2)]
(is (= (render f) (render (inc f))) (str "frame " (inc f) " must hold frame " f)))
;; Two grid slots that straddle a key, not two adjacent ones: between keys
;; nothing changes, because that is what hold MEANS. The scene's second key
@ -324,13 +335,13 @@
;; expose-before-anything-else rule showing up in pixels.
(is (not= (render 56) (render 58)) "and a key on the grid is seen")))
(deftest the-hand-written-scene-keeps-the-iris-and-pupil-inside-the-card
(deftest the-hand-written-clip-keeps-the-iris-and-pupil-inside-the-card
;; The stencil chain, on real pixels: the iris is clipped by the card and the
;; pupil by the iris, and neither is expressed anywhere as a chain.
(let [res (scene/resolver demo/scene)]
(doseq [f (range 0 (:frames demo/scene) 4)]
(let [before (raster/make (:width demo/scene) (:height demo/scene))
after (raster/make (:width demo/scene) (:height demo/scene))
(let [res (timeline/resolver demo/timeline)]
(doseq [f (range 0 demo/frames 4)]
(let [before (raster/make (:width demo/clip) (:height demo/clip))
after (raster/make (:width demo/clip) (:height demo/clip))
ops (res f)
card? (fn [op] (= :card (:node op)))]
(raster/clear! before (:bg pal/index-of))
@ -360,9 +371,9 @@
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))
day {:skin-base 1}
night {:skin-base 17}]
(is (= 1 (:color (first (scene/eval-frame s 0 nil day)))))
(is (= 17 (:color (first (scene/eval-frame s 0 nil night)))))
(is (= 17 (:color (first ((scene/resolver s nil night) 0))))
(is (= 1 (:color (first (timeline/eval-frame s 0 nil day)))))
(is (= 17 (:color (first (timeline/eval-frame s 0 nil night)))))
(is (= 17 (:color (first ((timeline/resolver s nil night) 0))))
"and the playback path agrees")))
(deftest a-tone-the-ramp-does-not-define-is-loudly-wrong
@ -370,7 +381,7 @@
;; authored data and should be impossible to miss.
(let [s (sc {:id :root :kind :group :z "a1"}
(poly :p :root "a1" [0 0 10 0 10 10] :skin-base))]
(is (= 255 (:color (first (scene/eval-frame s 0 nil {})))))))
(is (= 255 (:color (first (timeline/eval-frame s 0 nil {})))))))
(deftest partitioning-the-index-space-stops-two-palettes-colliding-on-a-stencil
;; A stencil is a colour key, so two nodes sharing a tone share a stencil —
@ -383,6 +394,6 @@
[:style :color] (ch/framed :iris)}})
;; :night's tones sit above :day's in one concatenated space
night {:eye-white 14 :iris 15}
ops (scene/eval-frame s 0 nil night)]
ops (timeline/eval-frame s 0 nil night)]
(is (= 14 (:stencil (second ops)))
"the stencil resolves to the index the stencil node actually drew in")))

View file

@ -54,7 +54,7 @@
(deftest a-whole-leaf-map-round-trips-through-parsed-json
;; What a save actually does: transit, then parsed so the column holds JSON.
(let [ls (leaf/leaves :c1 @take/scene)]
(let [ls (leaf/leaves :c1 @take/clip)]
(is (= ls (into {} (map (fn [[p v]] [p (round-json v)])) ls)))))
;; ---------------------------------------------------------------------------

View file

@ -2,9 +2,10 @@
(:require [cljs.test :refer [deftest is]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.landmarks :as lm]
[arthur.domain.palette :as pal]
[arthur.domain.scene :as scene]
[arthur.domain.timeline :as timeline]
[arthur.flow.condition.eyes :as condition-eyes]
[arthur.flow.ingest :as ingest]
[arthur.flow.measure.eyes :as eyes]
@ -45,20 +46,20 @@
(deftest the-full-take-hides-only-the-annotated-eye-and-returns-to-the-same-id
(let [presence (ingest/feature-presence take/frames {:eye-r [[10 14]]})
{:keys [scene store]} (flow-take/build
{:keys [clip store]} (flow-take/build
(assoc take/params :aspect 1 :name "observed-gap")
{:dense @take/analysis :presence presence})
sample (fn [id frame]
(ch/value-at (get-in scene [:nodes id :channels [:geom :pts]])
(ch/value-at (get-in (clip/nodes clip) [id :channels [:geom :pts]])
frame store))]
(is (empty? (scene/problems scene)))
(is (= [:eye-r :eye-l] (get-in scene [:groups :eyes-1 :members])))
(is (empty? (clip/problems clip)))
(is (= [:eye-r :eye-l] (get-in clip [:groups :eyes-1 :members])))
(doseq [f (range 9 14)]
(is (ch/nothing? (sample :eye-r f)))
(is (not (ch/nothing? (sample :eye-l f))))
(is (not (ch/nothing? (sample :mouth f)))))
(let [drawn (into #{} (map :node)
((scene/resolver scene store pal/index-of) 11))]
((timeline/resolver (clip/root clip) store pal/index-of) 11))]
(is (not (contains? drawn :eye-r)))
(is (not (contains? drawn :iris-r)))
(is (contains? drawn :eye-l))

View file

@ -11,12 +11,13 @@
(:require [cljs.test :refer [deftest is testing]]
[arthur.demo.take :as take]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.geom :as geom]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.raster :as raster]
[arthur.domain.ring :as ring]
[arthur.domain.scene :as scene]
[arthur.domain.timeline :as timeline]
[arthur.flow.freeze :as freeze]))
(def ^:private W 320)
@ -24,13 +25,17 @@
;; The whole vertical slice, exactly as the page builds it. Asserting against the
;; page's own clip rather than against a fixture built here is deliberate: a
;; fixture is a second scene nobody looks at, and the one that renders is the one
;; fixture is a second document nobody looks at, and the one that renders is the one
;; that has to be right.
(def clip (delay @take/frozen))
(def scene* (delay (:scene @clip)))
(def store (delay (:store @clip)))
(def frozen (delay @take/frozen))
(def clip* (delay (:clip @frozen)))
;; The ROOT TIMELINE, which is what an evaluator takes. `clip*` is the document —
;; `:fps`, the stage, the analysis, the tracking identities — and `tl*` is the bag
;; of nodes in its frame space. `timeline/resolver` refuses the wrong one loudly.
(def tl* (delay (clip/root @clip*)))
(def store (delay (:store @frozen)))
(defn- node [id] (get-in @scene* [:nodes id]))
(defn- node [id] (get-in @tl* [:nodes id]))
(defn- chan [id path] (get-in (node id) [:channels path]))
(defn- block-of
@ -44,16 +49,19 @@
(let [v (ch/value-at (chan id [:geom :pts]) f @store)]
(mapv #(ch/component v %) (range (.-length v)))))
(defn- ops-at [sc f]
((scene/resolver sc @store pal/index-of) f))
(defn- ops-at
"Ops for one frame of a TIMELINE."
[tl f]
((timeline/resolver tl @store pal/index-of) f))
(defn- render
"One frame of a scene into a byte buffer. The stage's size comes off the clip,
because project dimensions are the project's and not the footage's."
[sc f]
(let [r (raster/make (:width sc) (:height sc))]
"One frame of a CLIP into a byte buffer. The stage's size comes off the clip and
the ops off its root timeline, because project dimensions are the project's and
not the footage's — and not a timeline's either."
[c f]
(let [r (raster/make (:width c) (:height c))]
(raster/clear! r (get pal/index-of :bg))
(raster/draw-ops! r (ops-at sc f))
(raster/draw-ops! r (ops-at (clip/root c) f))
(vec (array-seq (:buf r)))))
(defn- drawn
@ -64,25 +72,27 @@
;; ---------------------------------------------------------------------------
;; the shape of what came out
(deftest the-frozen-take-is-a-valid-scene-in-every-head-mode
;; `scene/problems` is total by construction, so this is safe to run over data
;; before the data is trusted — which is what it is for.
(deftest the-frozen-take-is-a-valid-clip-in-every-head-mode
;; `clip/problems` is total by construction, so this is safe to run over data
;; 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 [mode [:as-filmed :locked]]
(let [sc (freeze/head-mode {:mode mode} @clip)]
(is (empty? (scene/problems sc)) (str mode ": " (pr-str (scene/problems sc))))))
(let [sc (freeze/head-mode {:mode :per-plate :kept #{0 12 40 88 150}} @clip)]
(is (empty? (scene/problems sc)) (pr-str (scene/problems sc)))))
(let [c (freeze/head-mode {:mode mode} @frozen)]
(is (empty? (clip/problems c)) (str mode ": " (pr-str (clip/problems c))))))
(let [c (freeze/head-mode {:mode :per-plate :kept #{0 12 40 88 150}} @frozen)]
(is (empty? (clip/problems c)) (pr-str (clip/problems c)))))
(deftest the-tree-is-the-one-the-model-specifies
;; :face is AUTHORED and :head is MEASURED, and they are two nodes because two
;; different things want that transform. A group node is free; keeping the
;; hand-placed and the measured transform apart is the whole reason the
;; transform is decomposed in the first place.
(is (= [:face :root] (scene/lineage (:nodes @scene*) :face)))
(is (= [:head :face :root] (scene/lineage (:nodes @scene*) :head)))
(is (= [:mouth :head :face :root] (scene/lineage (:nodes @scene*) :mouth)))
(is (= [:face :root] (timeline/lineage (:nodes @tl*) :face)))
(is (= [:head :face :root] (timeline/lineage (:nodes @tl*) :head)))
(is (= [:mouth :head :face :root] (timeline/lineage (:nodes @tl*) :mouth)))
(is (= [:mouth-in :mouth :head :face :root]
(scene/lineage (:nodes @scene*) :mouth-in)))
(timeline/lineage (:nodes @tl*) :mouth-in)))
;; Exposure lives on the clip root and inherits strictly.
(is (= {:mode :map :expose 2} (:time (node :root))))
(is (every? #(nil? (:time (node %))) [:face :head :mouth :mouth-in])))
@ -186,8 +196,8 @@
;; 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 [sc (freeze/head-mode {:mode :as-filmed} @clip)
res (scene/resolver sc @store pal/index-of)
(let [c (freeze/head-mode {:mode :as-filmed} @frozen)
res (timeline/resolver (clip/root c) @store pal/index-of)
k (first (:value (chan :face [:xform :scale])))
anc (:value (chan :face [:xform :anchor]))
pos (:value (chan :face [:xform :pos]))
@ -228,9 +238,9 @@
(deftest the-three-head-modes-are-the-three-channel-shapes
(let [kept #{0 12 40 88 150}
of (fn [sc path] (get-in sc [:nodes :head :channels path]))]
of (fn [c path] (get-in (clip/nodes c) [:head :channels path]))]
(testing "locked is framed identity"
(let [sc (freeze/head-mode {:mode :locked} @clip)]
(let [sc (freeze/head-mode {:mode :locked} @frozen)]
(is (= [:framed :framed :framed]
(mapv #(ch/describe (of sc %))
[[:xform :pos] [:xform :rot] [:xform :scale]])))
@ -238,12 +248,12 @@
(is (= 0.0 (:value (of sc [:xform :rot]))))
(is (= [1.0 1.0] (:value (of sc [:xform :scale]))))))
(testing "as filmed is dense"
(let [sc (freeze/head-mode {:mode :as-filmed} @clip)]
(let [sc (freeze/head-mode {:mode :as-filmed} @frozen)]
(is (= [:dense :dense :dense]
(mapv #(ch/describe (of sc %))
[[:xform :pos] [:xform :rot] [:xform :scale]])))))
(testing "per plate is keyed at exactly the kept frames"
(let [sc (freeze/head-mode {:mode :per-plate :kept kept} @clip)]
(let [sc (freeze/head-mode {:mode :per-plate :kept kept} @frozen)]
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale]]]
(is (= :keyed (ch/describe (of sc path))))
(is (= (sort kept) (sort (keys (:keys (of sc path))))))
@ -262,25 +272,29 @@
;; 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 :as-filmed} @clip)
b (freeze/head-mode {:mode :locked} @clip)
c (freeze/head-mode {:mode :per-plate :kept #{0 40}} @clip)]
(doseq [sc [b c]]
(is (= (get-in a [:nodes :face]) (get-in sc [:nodes :face]))
(let [a (freeze/head-mode {:mode :as-filmed} @frozen)
b (freeze/head-mode {:mode :locked} @frozen)
c (freeze/head-mode {:mode :per-plate :kept #{0 40}} @frozen)]
(doseq [x [b c]]
(is (= (get (clip/nodes a) :face) (get (clip/nodes x) :face))
":face moved")
(is (= (dissoc (:nodes a) :head) (dissoc (:nodes sc) :head))
(is (= (dissoc (clip/nodes a) :head) (dissoc (clip/nodes x) :head))
"a node other than :head changed")
;; The measurement does not go away when the head is locked: always measure,
;; always store factored, toggle the parent.
(is (= (get-in a [:nodes :head :measured]) (get-in sc [:nodes :head :measured]))))))
(is (= (get-in (clip/nodes a) [:head :measured])
(get-in (clip/nodes x) [:head :measured])))
;; And nothing above the timeline moved either: the toggle is one node's
;; channels, so the clip's own fields and its other timelines are untouched.
(is (= (dissoc a :timelines) (dissoc x :timelines))))))
(deftest a-head-mode-that-is-not-one-of-the-three-is-refused
(is (thrown-with-msg? ExceptionInfo #"not one of the three channel shapes"
(freeze/head-mode {:mode :stabilised} @clip)))
(freeze/head-mode {:mode :stabilised} @frozen)))
;; The kept-frame set belongs to the plate strip, not to measurement, so freeze
;; cannot invent one.
(is (thrown-with-msg? ExceptionInfo #"kept-frame set"
(freeze/head-mode {:mode :per-plate} @clip))))
(freeze/head-mode {:mode :per-plate} @frozen))))
;; ---------------------------------------------------------------------------
;; the face: authored, and what makes makeXform deletable
@ -290,7 +304,7 @@
;; transform on a node, which a hand can revise, and it claims no generator that
;; would offer to overwrite it.
(doseq [path [[:xform :pos] [:xform :rot] [:xform :scale] [:xform :anchor]]]
(let [c (get (node/channels (get-in @scene* [:nodes :face])) path)]
(let [c (get (node/channels (node :face)) path)]
(is (= :framed (ch/describe c)) (str path " is not framed"))
(is (nil? (:generated c)) (str path " claims provenance")))))
@ -314,18 +328,20 @@
;; re-measuring anything.
(let [big (freeze/clip (assoc take/params :stage [640 480] :name "big")
@take/measured)
key-of (fn [sc] (:store (:dense (get-in sc [:nodes :mouth :channels [:geom :pts]]))))]
(is (= [640 480] [(:width (:scene big)) (:height (:scene big))]))
key-of (fn [c] (:store (:dense (get-in (clip/nodes c)
[:mouth :channels [:geom :pts]]))))]
(is (= [640 480] [(:width (:clip big)) (:height (:clip big))]))
;; Stronger than it was, and for free: the stage is not an input to tier 2, so
;; the two clips do not merely hold equal bytes — they name the SAME BLOCK, and
;; a stage change cannot invalidate a bake. The clip's name is not an input
;; either, which is why "big" and "take" still agree.
(is (= (key-of @scene*) (key-of (:scene big)))
(is (= (key-of @clip*) (key-of (:clip big)))
"a different stage is a different document over the same tier 2")
(is (= (vec (array-seq (:data (get @store (key-of @scene*)))))
(vec (array-seq (:data (get (:store big) (key-of (:scene big)))))))
(is (= (vec (array-seq (:data (get @store (key-of @clip*)))))
(vec (array-seq (:data (get (:store big) (key-of (:clip big)))))))
"the geometry is the same numbers at either stage size")
(is (not= (:value (get-in (:scene big) [:nodes :face :channels [:xform :scale]]))
(is (not= (:value (get-in (clip/nodes (:clip big))
[:face :channels [:xform :scale]]))
(:value (chan :face [:xform :scale]))))))
;; ---------------------------------------------------------------------------
@ -355,7 +371,7 @@
;; interior comes and goes.
(is (nil? (chan :mouth [:vis])))
(doseq [f (range 0 take/frames 9)]
(is (some #(= :mouth (:node %)) (ops-at @scene* f))
(is (some #(= :mouth (:node %)) (ops-at @tl* f))
(str "frame " f " drew no mouth outline"))))
;; ---------------------------------------------------------------------------
@ -384,13 +400,15 @@
;; `:generated` out of the document and the frame is the same bytes. If this
;; ever fails, a rotoscoped part and a hand-drawn one have stopped being the
;; same data.
(let [stripped (update @scene* :nodes
(fn [ns] (into {} (map (fn [[id n]]
[id (update n :channels
#(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %))]))
ns)))]
(let [strip-node (fn [n]
(update n :channels
#(into {} (map (fn [[p c]] [p (dissoc c :generated)])) %)))
stripped (clip/update-root
@clip*
update :nodes
#(into {} (map (fn [[id n]] [id (strip-node n)])) %))]
(doseq [f (range 0 take/frames 17)]
(is (= (render @scene* f) (render stripped f))
(is (= (render @clip* f) (render stripped f))
(str "frame " f " differs with provenance removed")))))
;; ---------------------------------------------------------------------------
@ -401,21 +419,23 @@
presence {:eye-r (mapv #(not (contains? gap %)) (range take/frames))}
c (freeze/clip (assoc take/params :name "one-eye-gappy")
(assoc @take/measured :presence presence))
sc (:scene c)
tl (clip/root (:clip c))
at (fn [id f]
(ch/value-at (get-in sc [:nodes id :channels [:geom :pts]]) f (:store c)))]
(is (= [:eye-r :eye-l] (get-in sc [:groups :eyes-1 :members])))
(is (= :eye-r (get-in sc [:features :eye-r :id])))
(ch/value-at (get-in (:nodes tl) [id :channels [:geom :pts]]) f (:store c)))]
;; The identities are the CLIP's, and a gap does not touch them: a feature keeps
;; its id and its pair membership across the frames it was not observed on.
(is (= [:eye-r :eye-l] (get-in (:clip c) [:groups :eyes-1 :members])))
(is (= :eye-r (get-in (:clip c) [:features :eye-r :id])))
(is (not (ch/nothing? (at :eye-r 39))))
(is (ch/nothing? (at :eye-r 50)))
(is (not (ch/nothing? (at :eye-r 60))))
(is (not (ch/nothing? (at :eye-l 50))))
(is (not (ch/nothing? (at :mouth 50))))
(let [drawn-nodes (into #{} (map :node) ((scene/resolver sc (:store c) pal/index-of) 50))]
(let [drawn-nodes (into #{} (map :node) ((timeline/resolver tl (:store c) pal/index-of) 50))]
(is (not (contains? drawn-nodes :eye-r)))
(is (contains? drawn-nodes :eye-l))
(is (contains? drawn-nodes :mouth)))
(is (empty? (scene/problems sc)))))
(is (empty? (clip/problems (:clip c))))))
(deftest each-dense-track-follows-its-own-features-presence
;; Every feature is given a DIFFERENT gap, so a track wired to the wrong one
@ -437,9 +457,9 @@
windows)
c (freeze/clip (assoc take/params :name "per-track-gaps")
(assoc @take/measured :presence presence))
sc (:scene c)
tl (clip/root (:clip c))
at (fn [id path f]
(ch/value-at (get-in sc [:nodes id :channels path]) f (:store c)))
(ch/value-at (get-in (:nodes tl) [id :channels path]) f (:store c)))
;; Node, channel, and the feature whose gap it must follow. Every dense
;; track of the eye, iris, brow and brow-position blocks appears once.
tracks [[:eye-r [:geom :pts] :eye-r]
@ -452,7 +472,7 @@
[:brow-l [:geom :pts] :brow-l]
[:brow-r [:xform :pos] :brow-r]
[:brow-l [:xform :pos] :brow-l]]]
(is (empty? (scene/problems sc)))
(is (empty? (clip/problems (:clip c))))
(doseq [[id path owner] tracks
[feature gap] windows
f gap]
@ -465,10 +485,10 @@
(deftest teeth-follow-the-mouth-through-their-stencil-and-not-through-a-mask
;; Teeth are their own feature, so that they can carry their own :area :teeth
;; parameters — which means an occluded MOUTH sets no absence bit on them. They
;; do not need one: they are stencilled by :mouth-in, and scene/finish drops a
;; do not need one: they are stencilled by :mouth-in, and timeline/finish drops a
;; node whose stencil drew nothing. So the coupling is real, and it is the
;; stencil rule rather than the mask that enforces it. The rule itself is
;; asserted in scene-test; what is pinned here is the wiring that relies on it.
;; asserted in timeline-test; what is pinned here is the wiring that relies on it.
(let [gap (set (range 40 60))
teeth-in {;; The mouth's own inner ring standing in for a pixel-derived
;; contour: real geometry and no nils, so every frame has a
@ -485,7 +505,7 @@
;; Hoisted: the resolver caches its order and reuses its buffers, so the
;; node ids come out before the next frame is asked for.
nodes-at (fn [c]
(let [r (scene/resolver (:scene c) (:store c) pal/index-of)]
(let [r (timeline/resolver (clip/root (:clip c)) (:store c) pal/index-of)]
(fn [f] (into #{} (map :node) (r f)))))
ref-at (nodes-at ref)
occ-at (nodes-at occ)
@ -495,7 +515,7 @@
open (filter #(contains? (ref-at %) :teeth) (range take/frames))
inside (first (filter gap open))
outside (first (remove gap open))]
(is (= :mouth-in (get-in (:scene occ) [:nodes :teeth :stencil]))
(is (= :mouth-in (get-in (clip/nodes (:clip occ)) [:teeth :stencil]))
"teeth stop inheriting the mouth's absence if this stops being their stencil")
(is (some? inside) "no open-mouth frame inside the gap to test with")
(is (some? outside) "no open-mouth frame outside the gap to test with")
@ -503,14 +523,14 @@
;; Annotating the MOUTH sets no bit on the teeth: they are a feature of
;; their own and nothing named them.
(is (not (ch/nothing?
(ch/value-at (get-in (:scene occ) [:nodes :teeth :channels [:geom :pts]])
(ch/value-at (get-in (clip/nodes (:clip occ)) [:teeth :channels [:geom :pts]])
inside (:store occ)))))
;; They are dropped anyway — :mouth-in drew nothing to clip them against.
(is (not (contains? (occ-at inside) :mouth-in)))
(is (not (contains? (occ-at inside) :teeth)))
;; And the same articulation outside the gap still draws them.
(is (contains? (occ-at outside) :teeth)))
(is (empty? (scene/problems (:scene occ))))))
(is (empty? (clip/problems (:clip occ))))))
(deftest an-undetected-frame-has-no-pose-at-all
;; A subject that was not on the frame has NO VALUE, which is different from a
@ -521,8 +541,8 @@
det (mapv #(not (contains? gap %)) (range take/frames))
c (freeze/clip (assoc take/params :name "gappy")
(assoc @take/measured :detected det))
sc (:scene c)
res (scene/resolver sc (:store c) pal/index-of)]
tl (clip/root (:clip c))
res (timeline/resolver tl (:store c) pal/index-of)]
(doseq [f [39 40 50 59 60]]
(let [ops (res f)]
(if (contains? gap f)
@ -531,7 +551,7 @@
;; And it is the MASK doing it, not a hidden flag: `[:vis]` on :mouth-in is
;; unchanged across the gap, because hiding and absence are different
;; questions with different answers.
(is (= (mapv #(ch/value-at (get-in sc [:nodes :mouth-in :channels [:vis]]) %)
(is (= (mapv #(ch/value-at (get-in (:nodes tl) [:mouth-in :channels [:vis]]) %)
(range take/frames))
(mapv #(ch/value-at (chan :mouth-in [:vis]) %) (range take/frames))))))
@ -569,7 +589,7 @@
(deftest the-take-draws-something-on-every-frame
(doseq [f (range 0 take/frames 5)]
(let [n (drawn (render @scene* f))]
(let [n (drawn (render @clip* f))]
(is (> n 200) (str "frame " f " drew only " n " pixels")))))
(deftest the-mouth-moves
@ -577,10 +597,10 @@
;; 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 :locked} @clip)
(let [locked (freeze/head-mode {:mode :locked} @frozen)
shot (fn [f]
(let [r (raster/make W H)
mouth (filter #(= :mouth (:node %)) (ops-at locked f))]
mouth (filter #(= :mouth (:node %)) (ops-at (clip/root locked) f))]
(raster/clear! r (get pal/index-of :bg))
(raster/draw-ops! r mouth)
(vec (array-seq (:buf r)))))
@ -594,6 +614,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 :as-filmed} @clip)]
(let [filmed (freeze/head-mode {:mode :as-filmed} @frozen)]
(is (> (count (remove true? (map = (render filmed 10) (render filmed 120)))) 300)
"the head does not move across the take")))

View file

@ -1,10 +1,10 @@
(ns arthur.support.ops
"Comparing two evaluations of a scene, frame for frame.
"Comparing two evaluations of a timeline, frame for frame.
`domain/scene` has two evaluators on purpose — `eval-frame` is the
specification and `resolver` is what playback uses — and scene-test's central
`domain/timeline` has two evaluators on purpose — `eval-frame` is the
specification and `resolver` is what playback uses — and timeline-test's central
assertion is that they agree in forward, backward and random frame order.
Step 9 needs the same comparison for a different question: that a scene which
Step 9 needs the same comparison for a different question: that a document which
has been through the server produces the same ops as the one that went in.
Shared rather than copied, because the interesting part is not the equality —
@ -12,12 +12,15 @@
point buffer per node, so it can agree on a forward pass and disagree on a
scrub, and a copy of this list that forgot 'backward' would test the easy half.
Every entry point takes a TIMELINE, not a clip: what produces ops is a bag of
nodes in a frame space, and a caller with a clip says `(clip/root c)`.
`snapshot` is what makes ops comparable at all: a resolved op carries `:pts` as
a VIEW into a reused buffer, so two ops from different frames can be `=` while
naming the same array, and holding one and then asking for the next frame
changes what the first one says. Reading the points out is what pins the frame."
(:require [arthur.domain.palette :as pal]
[arthur.domain.scene :as scene]))
[arthur.domain.timeline :as timeline]))
(defn points
"An op's points as a vector of [x y], read out of its buffer."
@ -27,7 +30,7 @@
(defn snapshot
"Ops -> comparable data. `:i` goes too: it is the draw-order index, and it is a
function of the scene rather than of the frame."
function of the timeline rather than of the frame."
[ops]
(mapv (fn [op]
(cond-> (dissoc op :pts :i)
@ -47,13 +50,13 @@
(defn specified
"(fn [f] -> snapshot) through `eval-frame`, the specification."
([scene store] (specified scene store pal/index-of))
([scene store palette]
(fn [f] (snapshot (scene/eval-frame scene f store palette)))))
([tl store] (specified tl store pal/index-of))
([tl store palette]
(fn [f] (snapshot (timeline/eval-frame tl f store palette)))))
(defn resolved
"(fn [f] -> snapshot) through `resolver`, the playback path."
([scene store] (resolved scene store pal/index-of))
([scene store palette]
(let [res (scene/resolver scene store palette)]
([tl store] (resolved tl store pal/index-of))
([tl store palette]
(let [res (timeline/resolver tl store palette)]
(fn [f] (snapshot (res f))))))

View file

@ -186,7 +186,7 @@ const PROBE = `(() => {
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
hash: h >>> 0,
frame: document.querySelector('.readout span')?.textContent ?? '',
scene: [...document.querySelectorAll('.transport .row button')]
selectedClip: [...document.querySelectorAll('.transport .row button')]
.filter((b) => b.classList.contains('on')).map((b) => b.textContent),
};
})()`;
@ -235,9 +235,9 @@ async function main() {
}
if (!probe) throw new Error('no canvas.stage on the page — is `shadow-cljs watch app` running?');
console.log(`\ncanvas ${probe.w}x${probe.h}, scene ${JSON.stringify(probe.scene)}`);
console.log(`\ncanvas ${probe.w}x${probe.h}, clip ${JSON.stringify(probe.selectedClip)}`);
check(probe.scene.includes('take'), 'the take is the scene that opens');
check(probe.selectedClip.includes('take'), 'the take is the clip that opens');
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
`${probe.w}x${probe.h}`);
check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`);
@ -384,8 +384,8 @@ async function main() {
'and the open mouth still has an interior on the far side');
// The reopened clip is not one of the built-ins: this is the document that came
// back from the server, not the one that was in the page all along.
check(!back[0].scene.includes('take'), 'the picture is the reopened document',
JSON.stringify(back[0].scene));
check(!back[0].selectedClip.includes('take'), 'the picture is the reopened document',
JSON.stringify(back[0].selectedClip));
check(page.logs.length === 0, 'no errors on the console',
page.logs.slice(0, 3).join(' | '));