284 lines
16 KiB
Clojure
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))))))))
|