arthur/frontend/test/arthur/domain/cadence_test.cljs

284 lines
16 KiB
Clojure

(ns arthur.domain.cadence-test
(:require [cljs.test :refer [deftest is testing]]
[arthur.domain.bring :as bring]
[arthur.domain.cadence :as cadence]
[arthur.domain.channel :as ch]
[arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf]
[arthur.domain.nest :as nest]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.pick :as pick]
[arthur.domain.pose :as pose]
[arthur.ui.timeline :as timeline]))
(def footage
{:fps 30 :width 100 :height 100
:symbols {:main {:id :main :fps 30 :frames 60
:nodes {:mark {:id :mark :kind :rect :z "a" :pose-group :mouth
:channels {[:geom :size] {:animated? true :pose-sampled? true
:dense {:store "sizes" :offset 0 :stride 1 :frames 60}}
[:style :color] (ch/framed :brow)}}}}}})
(def store {"sizes" {:data (js/Float64Array. (clj->js (range 1 61)))}})
(deftest selection-is-integral-and-never-early
(is (= [0 2 5 7 10 12] (mapv #(cadence/frame % 12 30) (range 6))))
(doseq [grid [8 12 24 30 60] native [12 24 30 60] f (range 90)]
(let [selected (cadence/frame f grid native)]
(is (and (integer? selected) (<= selected (/ (* f native) grid))
(< (- (/ (* f native) grid) selected) 1))))))
(deftest changing-output-fps-preserves-dense-content-and-seconds
(let [doc (clip/set-fps footage 12)
draw (clip/resolver doc :main store pal/index-of nil)]
(is (= 24 (clip/output-frames doc :main)))
(is (= (:symbols footage) (:symbols doc)))
(is (= footage (clip/set-fps doc 30)))
(is (= doc (leaf/clip "test" (leaf/leaves "test" doc))))
(is (= [1 3 6 8 11 13] (mapv #(:size (first (draw %))) (range 6))))
;; `nest/inside` and the timeline's rows are in the SYMBOL's own frames --
;; see `docs/one-grid-plan.md`. Frame 7 of :main is frame 7 of :main; the
;; output frame that shows it is 3, and `shown-frame` is the one function
;; that crosses between the two.
(is (= 7 (:frame (nest/inside doc store :main [:mark] 7))))
(is (= 7 (clip/shown-frame doc :main 3)))
(is (= 3 (clip/first-output-frame doc :main 7)))
(is (= [0 60] (:span (first (timeline/rows doc :main #{})))))))
(deftest changing-fps-before-authoring-moves-the-empty-canvas-to-that-grid
(let [doc (clip/set-fps (clip/blank) 12)]
(is (= 12 (:fps doc)))
(is (nil? (get-in doc [:symbols :main :fps])))
(is (= 48 (clip/frames doc :main)))
(is (= 48 (clip/output-frames doc :main)))
(is (= 8 (clip/shown-frame doc :main 8)))
(is (= 8 (clip/first-output-frame doc :main 8)))))
(deftest changing-fps-after-authoring-preserves-the-symbols-native-grid
(let [started (assoc-in (clip/blank) [:symbols :main :nodes :mark]
{:id :mark :kind :rect :z "a"})
doc (clip/set-fps started 12)]
(is (= 30 (get-in doc [:symbols :main :fps])))
(is (= 120 (clip/frames doc :main)))
(is (= 48 (clip/output-frames doc :main)))))
(deftest project-fps-is-the-root-symbols-editing-grid
(let [doc (-> (clip/blank)
(assoc-in [:symbols :main :nodes :child]
{:id :child :kind :instance :z "a"
:source {:symbol :nested}})
(assoc-in [:symbols :nested]
{:id :nested :fps 30 :frames 90 :nodes {}})
(clip/set-root-fps 12))]
(is (= 12 (:fps doc)) "the output grid")
(is (= 12 (clip/fps doc :main)) "is also the root editing grid")
(is (= 30 (clip/fps doc :nested)) "while a nested symbol keeps its own grid")
(is (= 120 (clip/frames doc :main)) "frame positions are not rewritten")))
(deftest crossing-to-the-output-grid-and-back-lands-on-the-frame-it-names
;; `first-output-frame` is the inverse of `shown-frame` as far as a floor has
;; one: seeking to the output frame it names puts the playhead on a frame at or
;; after the mark, never before it, and exactly on it whenever an output frame
;; shows it at all.
(doseq [project [8 12 24 30 60] native [12 24 30 60]]
(let [doc (-> footage (assoc :fps project) (assoc-in [:symbols :main :fps] native))]
(doseq [n (range 40)]
(let [f (clip/first-output-frame doc :main n)]
(is (integer? f))
(is (>= (clip/shown-frame doc :main f) n)
(str n " at " project "/" native))
(is (or (zero? f) (< (clip/shown-frame doc :main (dec f)) n))))))))
(deftest imported-footage-uses-selection-for-picture-and-real-speed-for-audio
(let [tracked (-> footage
(assoc :analyses {"a" {:id "a"}}
:subjects {:face-1 {:id :face-1 :analysis "a"
:source-subject :face-1}}
:features {} :groups {}
:symbols {:main {:id :main :fps 30 :frames 60
:nodes {:face-1 {:id :face-1 :kind :instance
:z "a" :source {:symbol :face-1}}}}
:face-1 (get-in footage [:symbols :main])}))
{doc :clip sid :sid} (bring/take (clip/set-fps (clip/blank) 12)
tracked "take" "video" [15 75])
;; A host authored at 12, with a 30fps source starting half a second in.
doc (-> doc (assoc-in [:symbols :main :fps] 12)
(clip/place-symbol store :main sid 6 :insert nil))
n (get-in doc [:symbols :main :nodes :insert])
draw (clip/resolver doc :main store pal/index-of nil)
[sound] (nest/audio-tracks doc :main)]
(is (= 60 (clip/frames doc sid)))
(is (= 1 (get-in doc [:symbols :take.face-1 :nodes :sound :time :rate])))
(is (= [0 24] (:span n)))
(is (empty? (draw 5)))
(is (= 8 (:size (first (draw 9)))))
(is (= 7 (:frame (nest/inside doc store :main [:insert :face-1 :mark] 9))))
(is (= [-4 -4 4 4] ((pick/bounds-of doc store :main n) 3)))
(is (= [6 30] (node/placed-span sound)))
(is (= 15 (node/local-frame sound 6)))
(is (= 1 (* (get-in sound [:time :rate]) (/ (:fps doc) (:fps sound)))))
(is (= [6 30] (:span (first (timeline/rows doc :main #{[:insert]})))))
(testing "a deliberate half speed still retimes audio"
(let [slow (assoc-in doc [:symbols :main :nodes :insert :playback :speed] 0.5)
[track] (nest/audio-tracks slow :main)]
(is (= 0.5 (* (get-in track [:time :rate]) (/ (:fps slow) (:fps track)))))))))
(deftest generated-faces-own-their-footage-sound
(let [face (fn [id] {:id id :fps 30 :frames 20 :nodes {}})
frozen {:fps 30 :analyses {"a" {:id "a"}}
:subjects {:face-1 {:id :face-1 :analysis "a" :source-subject :face-1}
:face-2 {:id :face-2 :analysis "a" :source-subject :face-2}}
:symbols {:main {:id :main :fps 30 :frames 20
:nodes {:face-1 {:id :face-1 :kind :instance :z "a1"
:source {:symbol :face-1}}
:face-2 {:id :face-2 :kind :instance :z "a2"
:source {:symbol :face-2}}}}
:face-1 (face :face-1)
:face-2 (face :face-2)}}
{doc :clip take :sid} (bring/take (clip/blank) frozen "take" "video" [3 13])
alone (clip/place-symbol doc nil :main :take.face-1 4 :placed-face nil)]
(is (nil? (get-in doc [:symbols take :nodes :sound]))
"the wrapper does not own a sound the face would lose")
(is (= {:footage "video"}
(get-in doc [:symbols :take.face-1 :nodes :sound :source])))
(is (= 1 (count (nest/audio-tracks doc take)))
"the same take recording is not mixed once per detected face")
(is (= 1 (count (nest/audio-tracks alone :main)))
"placing a generated face by itself brings its associated sound")))
;; ---------------------------------------------------------------------------
;; the preserve-snap
;;
;; `footage` above is the cadence with no opinion about content: at 12 out of 30
;; it reads 0, 2, 5, 7, 10, 12, 15 … and the frames between those it shows to
;; nobody. `closure` is the same take with something in one of those gaps worth
;; seeing — docs/frame-selection.md's synthetic case, a mouth that shuts for ONE
;; native frame, 13, which no 12-from-30 slot ever samples.
(defn- sized
"A dense `[:geom :size]` over the whole take. The store holds 1..60, so a DRAWN
SIZE NAMES THE NATIVE FRAME it was read from: size = frame + 1. That is the
whole reason the fixture draws rects — the picture says which frame it is."
[]
{:animated? true :dense {:store "sizes" :offset 0 :stride 1 :frames 60}})
(def closure
"A head that holds still and a mouth that shuts for one native frame.
The cut is the one `flow/freeze` already stores and nothing new: `[:vis]` keys
on the interior, `:hold`, provenance `:roto/mouth-aperture`, hidden meaning
shut. The head's size is NOT `:pose-sampled?` and carries no `:pose-group`,
which is what makes it the control — a head reads the cadence whatever the
mouth does."
{:fps 30 :width 100 :height 100
:symbols
{:face
{:id :face :fps 30 :frames 60
:nodes
{:head {:id :head :kind :rect :z "a"
:channels {[:geom :size] (sized)
[:style :color] (ch/framed :brow)}}
:mouth-in {:id :mouth-in :kind :rect :parent :head :z "b"
:pose-group :mouth
:channels {[:geom :size] (assoc (sized) :pose-sampled? true)
[:style :color] (ch/framed :mouth-dark)
[:vis] (assoc (ch/keyed {0 true, 13 false, 14 true} :hold)
:pose-sampled? true
:generated {:by :roto/mouth-aperture})}}}}}})
(defn- drawn
"What each node draws at output frame `f`, as `node -> size`. A node missing
from the map was not drawn at all, which for the mouth interior IS the shut
mouth: `[:vis]` false takes the part off the frame."
[r f]
(into {} (map (juxt :node :size)) (r f)))
(deftest a-dropped-closure-reaches-exactly-one-output-frame
(let [doc (clip/set-fps closure 12)
n (clip/output-frames doc :face)
r (clip/resolver doc :face store pal/index-of {:snap #{:face}})
fs (mapv #(drawn r %) (range n))]
(is (= 24 n))
(is (not-any? #{13} (map #(cadence/frame % 12 30) (range n)))
"the cadence alone never samples the closure — that is the gap")
(testing "off is the cadence alone, and is what an opts map saying nothing gets"
(doseq [[what opts] [["no opts at all" nil]
["another face switched on" {:snap #{:somebody-else}}]]]
(let [off (clip/resolver doc :face store pal/index-of opts)]
(is (every? #(contains? (drawn off %) :mouth-in) (range n))
(str "with " what " the closure is never recovered"))
(is (= (mapv #(inc (cadence/frame % 12 30)) (range n))
(mapv #(:mouth-in (drawn off %)) (range n)))
(str "with " what " every slot reads what the grid says")))))
(is (= [6] (filterv #(not (contains? (nth fs %) :mouth-in)) (range n)))
"the shut mouth reaches exactly one output frame")
(is (= [1 3 6 8 11 13 nil 18] (mapv :mouth-in (take 8 fs)))
"slot 6 snaps back from 15 to 13; no other slot moves")
(is (= (mapv #(inc (cadence/frame % 12 30)) (range n)) (mapv :head fs))
"and the head reads what the cadence says, on every frame including 6")
(testing "through an instance, where the grid becomes native at the boundary"
(let [host (-> doc
(assoc-in [:symbols :stage] {:id :stage :fps 12 :frames 24 :nodes {}})
(clip/place-symbol store :stage :face 0 :cel nil))
hr (clip/resolver host :stage store pal/index-of {:snap #{:face}})
hfs (mapv #(drawn hr %) (range 24))]
(is (= [6] (filterv #(not (contains? (nth hfs %) [:cel :mouth-in])) (range 24))))
(is (= [1 3 6 8 11 13 nil 18] (mapv #(get % [:cel :mouth-in]) (take 8 hfs))))
(is (= (mapv #(inc (cadence/frame % 12 30)) (range 24))
(mapv #(get % [:cel :head]) hfs)))
(is (= (mapv #(inc (cadence/frame % 12 30)) (range 24))
(let [hostly (clip/resolver host :stage store pal/index-of
{:snap #{:stage}})]
(mapv #(get (drawn hostly %) [:cel :mouth-in]) (range 24))))
"the switch is the FACE's: naming the host that places it marks nothing")
(testing "a hand cut beats a snap, always"
(let [held (pose/put-cut host :stage :cel :mouth 0 0)
kr (clip/resolver held :stage store pal/index-of {:snap #{:face}})
kfs (mapv #(drawn kr %) (range 24))]
(is (= (vec (repeat 24 1)) (mapv #(get % [:cel :mouth-in]) kfs))
"the hand said hold frame 0, so the closure is never recovered")))))))
(deftest preserve-marks-are-the-frames-a-closure-cut-calls-shut
(let [nodes (get-in closure [:symbols :face :nodes])
vis (fn [ch] {:solo {:id :solo :kind :rect :pose-group :solo
:channels {[:vis] ch}}})]
(is (= {:mouth [13]} (pose/marks nodes 60 store))
"one mark, per pose group, for the one frame the cut calls shut")
(is (nil? (pose/marks (select-keys nodes [:head]) 60 store))
"a node with no [:vis] has no closure and gets no marks")
(is (nil? (pose/marks nodes nil store))
"a symbol with no length has no frames to mark")
(testing "a hidden feature is not a closure"
(is (nil? (pose/marks (vis (assoc (ch/keyed {0 true, 13 false, 14 true} :hold)
:generated {:by :pixels/teeth}))
60 store))
"the teeth being occluded says nothing about a mouth shutting")
(is (nil? (pose/marks (vis (ch/keyed {0 true, 13 false, 14 true} :hold)) 60 store))
"and a hand-keyed [:vis] with no provenance is not a cut either"))
(testing "an absent measurement is not a closure"
(is (nil? (pose/marks (vis (assoc (ch/keyed {} :hold)
:generated {:by :roto/blink}))
60 store))
"no value on a frame is not the same fact as shut on it"))))
(deftest a-slot-snaps-back-to-a-mark-and-never-forward
(let [m (pose/marks (get-in closure [:symbols :face :nodes]) 60 store)]
(is (= 13 (pose/snapped-frame m :mouth 12 15))
"slot 6, whose default is 15, reads 13")
(is (= 12 (pose/snapped-frame m :mouth 10 12))
"slot 5 does NOT reach forward from 12 to 13 — that would lead the cut")
(is (= 17 (pose/snapped-frame m :mouth 15 17))
"slot 7 cannot reach back past its own gap to a frame slot 6 showed")
(is (= 13 (pose/snapped-frame m :mouth 12 13))
"a mark that IS the default is simply the default")
(is (= 15 (pose/snapped-frame m :mouth 15 15))
"an empty interval — a slot reading what the one before it read — snaps nothing")
(is (= 15 (pose/snapped-frame m :other 12 15))
"marks are per group: another group's closure is not this one's")
(is (= 15 (pose/snapped-frame nil :mouth 12 15))
"and no marks at all is the cadence back again")
(testing "never later than the slot's own instant, over every grid and native pair"
(doseq [grid [8 12 24 30 60] native [12 24 30 60] f (range 90)]
(let [hi (cadence/frame f grid native)
lo (if (pos? f) (cadence/frame (dec f) grid native) -1)]
(is (<= (pose/snapped-frame m :mouth lo hi) (/ (* f native) grid))))))))