diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index ad1d85f..63dbfd0 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -44,10 +44,13 @@ `:head` written as a dense track in one and as framed identity in the other, so the button that switches between them switches a document field and nothing else." - {:demo (entry :demo "demo" demo/clip nil) - :swarm (entry :swarm "swarm" @swarm/clip @swarm/store) - :take (entry :take "take" @take/clip @take/store) - :take-locked (entry :take-locked "locked" @take/locked @take/store)}) + {:demo {:label "demo" :entry (delay (entry :demo "demo" demo/clip nil))} + :swarm {:label "swarm" :entry (delay (entry :swarm "swarm" @swarm/clip @swarm/store))} + :take {:label "take" :entry (delay (entry :take "take" @take/clip @take/store))} + :take-locked {:label "locked" :entry (delay (entry :take-locked "locked" @take/locked @take/store))}}) + +(defn clip-entry [id] + (some-> (get-in clips [id :entry]) deref)) (def default {;; --- the document --- @@ -60,7 +63,7 @@ ;; footage's. That is what deleting `makeXform` buys — the framing became a ;; transform on a node, so nothing downstream of the freeze knows the frame ;; size — and it is why ui/player no longer hardcodes 320x200. - :clip (select-keys (:take clips) [:fps :frames :width :height :audio :display-fps]) + :clip (select-keys (clip-entry :take) [:fps :frames :width :height :audio :display-fps]) ;; Which ingested footage to detect, and what the last load said. The list ;; comes from the server — tier 3 is the backend's since step 9 — so there is diff --git a/frontend/src/arthur/demo/stage.cljs b/frontend/src/arthur/demo/stage.cljs index 8d87c4f..16066ad 100644 --- a/frontend/src/arthur/demo/stage.cljs +++ b/frontend/src/arthur/demo/stage.cljs @@ -1,21 +1,41 @@ (ns arthur.demo.stage - "A saved take placed twice on a stage. The EDN is the authored layout." - (:require [cljs.reader :as reader] + "A saved take placed seven times on a stage. The EDN is the authored layout." + (:require [arthur.domain.channel :as ch] + [cljs.reader :as reader] [shadow.resource :as rc])) (def layout (reader/read-string (rc/inline "arthur/demo/stage_8625.edn"))) +(defn- position-track [center anchor drift phase frames] + (let [base (mapv - center anchor) + [dx dy] drift + wave (fn [f period] (js/Math.sin (* 2 js/Math.PI (/ (+ f phase) period)))) + x0 (wave 0 96) + y0 (wave 0 132)] + (ch/keyed + (into {} + (for [f (conj (vec (range 0 frames 20)) (dec frames))] + [f [(+ (first base) (* dx (- (wave f 96) x0))) + (+ (second base) (* dy (- (wave f 132) y0)))]])) + :linear))) + (defn compose [source] - (let [{:keys [name width height frames symbol instances audio]} layout + (let [{:keys [name width height frames symbol instances audio scale]} layout + default-anchor (or (:anchor layout) + [(/ (:width source) 2) (/ (:height source) 2)]) original (get-in source [:timelines :main]) nodes (into {:root {:id :root :name "stage" :kind :group :z "a1"}} - (map (fn [{:keys [id name z span at in pos scale]}] - [id {:id id :name name :kind :symbol :of symbol - :parent :root :z z :span span - :time {:mode :map :at at :in in :rate 1} - :channels {[:xform :pos] {:animated? false :value pos} - [:xform :scale] {:animated? false :value scale}}}]) + (map (fn [{:keys [id name z span at in center anchor drift phase]}] + (let [anchor (or anchor default-anchor)] + [id {:id id :name name :kind :symbol :of symbol + :parent :root :z z :span span + :time {:mode :map :at at :in in :rate 1} + :channels {[:xform :pos] (if drift + (position-track center anchor drift phase frames) + (ch/framed (mapv - center anchor))) + [:xform :anchor] {:animated? false :value anchor} + [:xform :scale] scale}}])) instances)) nodes (into nodes (map (fn [{:keys [id linked-to z source span at in gain pan]}] diff --git a/frontend/src/arthur/demo/stage_8625.edn b/frontend/src/arthur/demo/stage_8625.edn index 3596ec3..ca921f1 100644 --- a/frontend/src/arthur/demo/stage_8625.edn +++ b/frontend/src/arthur/demo/stage_8625.edn @@ -1,11 +1,22 @@ ;; A local stage sketch. The source is the saved, post-processed IMG_8625.MOV -;; clip in the project store; its dense channel blocks remain shared by both -;; instances. Positions and timing are authored here in stage pixels and frames. +;; clip in the project store; its dense channel blocks are shared by all seven +;; instances. Centers, drift and timing are authored in stage pixels and frames. {:source-project "4379f900-bdd2-409b-acf6-32081f8ce01f" :source-cid "f8cace9e-4ad3-4796-973c-c62eeebe3d01" :symbol :sym/face-8625 :name "8625 stage study" :width 320 :height 200 :frames 280 + ;; Each :center below places the source clip's center on the stage. A symbol + ;; can author :anchor to override that default for an off-center drawing. + ;; Each placement reads this pulse in its own local time, so the staggered + ;; entrances start their growth at different moments on the master timeline. + :scale {:animated? true :interp :linear + :keys {0 [0.4 0.4], 12 [0.56 0.56], 24 [0.48 0.48], + 48 [0.52 0.52], 72 [0.48 0.48], 96 [0.52 0.52], + 120 [0.48 0.48], 144 [0.52 0.52], 168 [0.48 0.48], + 192 [0.52 0.52], 216 [0.48 0.48], 240 [0.52 0.52], + 279 [0.48 0.48]} + :over []} ;; Audio placements are ordinary timeline nodes with channel parameters. ;; :linked-to is an editorial link; their spans and time maps are independent. :audio @@ -26,7 +37,22 @@ :instances [{:id :left :name "8625 left" :z "a1" :span [0 280] :at 0 :in 0 - :pos [12 38] :scale [0.62 0.62]} + :center [40 40] :drift [3 2] :phase 0} {:id :right :name "8625 right" :z "a2" :span [48 280] :at 48 :in 0 - :pos [108 38] :scale [0.62 0.62]}]} + :center [120 40] :drift [-3 2] :phase 17} + {:id :top-third :name "8625 top third" :z "a5" + :span [24 280] :at 24 :in 0 + :center [200 40] :drift [2 -3] :phase 31} + {:id :top-fourth :name "8625 top fourth" :z "a6" + :span [72 280] :at 72 :in 0 + :center [280 40] :drift [-2 -2] :phase 49} + {:id :bottom-left :name "8625 bottom left" :z "a7" + :span [96 280] :at 96 :in 0 + :center [70 135] :drift [3 -2] :phase 63} + {:id :bottom-middle :name "8625 bottom middle" :z "a8" + :span [120 280] :at 120 :in 0 + :center [160 135] :drift [-2 3] :phase 81} + {:id :bottom-right :name "8625 bottom right" :z "a9" + :span [144 280] :at 144 :in 0 + :center [250 135] :drift [2 2] :phase 107}]} diff --git a/frontend/src/arthur/domain/channel.cljs b/frontend/src/arthur/domain/channel.cljs index 4933148..7131df3 100644 --- a/frontend/src/arthur/domain/channel.cljs +++ b/frontend/src/arthur/domain/channel.cljs @@ -172,8 +172,11 @@ (defn- interpolate [ch f left right] (let [a (get (:keys ch) left)] (if (and (= :linear (:interp ch)) right (>= f left) (> right left)) - (let [b (get (:keys ch) right)] - (+ a (* (/ (- f left) (- right left)) (- b a)))) + (let [b (get (:keys ch) right) + t (/ (- f left) (- right left))] + (if (vector? a) + (mapv (fn [x y] (+ x (* t (- y x)))) a b) + (+ a (* t (- b a))))) a))) (defn- keyed-at @@ -291,7 +294,16 @@ (defn problems "Human-readable reasons this map is not a channel. Empty means it is one." [ch] - (cond-> [] + (let [values (when (map? (:keys ch)) (vals (:keys ch))) + first-value (first values) + linear-values? (or (every? number? values) + (and (vector? first-value) + (pos? (count first-value)) + (every? (fn [v] (and (vector? v) + (= (count v) (count first-value)) + (every? number? v))) + values)))] + (cond-> [] (not (map? ch)) (conj "not a map") @@ -315,8 +327,8 @@ (conj (str ":interp " (:interp ch) " is not implemented")) (and (map? ch) (= :linear (:interp ch)) - (or (:dense ch) (not (every? number? (vals (:keys ch)))))) - (conj ":linear interpolation needs numeric keyed values") + (or (:dense ch) (not linear-values?))) + (conj ":linear interpolation needs numeric keys of one shape") (and (map? ch) (seq (:over ch))) (conj ":over layers are not implemented (port-plan step 2 scope)") @@ -328,4 +340,4 @@ (not (and (number? (:scale (:dense ch))) (pos? (:scale (:dense ch)))))) (conj (str ":dense :scale is " (pr-str (:scale (:dense ch))) " — a fixed-point scale is a positive number the stored integers" - " were multiplied by")))) + " were multiplied by"))))) diff --git a/frontend/src/arthur/footage/store.cljs b/frontend/src/arthur/footage/store.cljs index 08b7c66..75f3a38 100644 --- a/frontend/src/arthur/footage/store.cljs +++ b/frontend/src/arthur/footage/store.cljs @@ -29,4 +29,4 @@ (defn entry [id] (if (= id (:id @loaded)) @loaded - (get db/clips id))) + (db/clip-entry id))) diff --git a/frontend/src/arthur/ui/player.cljs b/frontend/src/arthur/ui/player.cljs index 27efe72..cfc7225 100644 --- a/frontend/src/arthur/ui/player.cljs +++ b/frontend/src/arthur/ui/player.cljs @@ -151,7 +151,14 @@ (js/performance.mark "arthur/blit:start") (canvas/blit! canvas ras ramp)) (js/performance.measure "arthur/resolve+draw" "arthur/paint:start" "arthur/blit:start") - (js/performance.measure "arthur/paint" "arthur/paint:start")))) + (js/performance.measure "arthur/paint" "arthur/paint:start") + ;; User Timing entries otherwise accumulate forever in the browser's + ;; performance timeline during playback. DevTools captures the events as + ;; they happen; keeping copies on the page serves no purpose. + (js/performance.clearMarks "arthur/paint:start") + (js/performance.clearMarks "arthur/blit:start") + (js/performance.clearMeasures "arthur/resolve+draw") + (js/performance.clearMeasures "arthur/paint")))) (defonce ^:private watch ;; A scene swap changes the resolver and not the frame number, so the loop diff --git a/frontend/test/arthur/domain/channel_test.cljs b/frontend/test/arthur/domain/channel_test.cljs index b4438d0..af93f77 100644 --- a/frontend/test/arthur/domain/channel_test.cljs +++ b/frontend/test/arthur/domain/channel_test.cljs @@ -21,6 +21,15 @@ (is (= [:a :a :a :a :b :b :b :b :b :b :b :b :c :c] (mapv #(ch/value-at c %) (range 0 14)))))) +(deftest linear-vector-keys-interpolate-each-component + (let [c (ch/keyed {0 [0.4 0.6], 10 [0.6 0.4]} :linear) + frames [0 5 10 5 2] + cursor (ch/cursor c)] + (is (empty? (ch/problems c))) + (is (= [0.5 0.5] (ch/value-at c 5))) + (is (= (mapv #(ch/value-at c %) frames) + (mapv #(ch/sample! cursor %) frames))))) + (deftest a-frame-before-the-first-key-reads-the-first-key ;; The JS activeKey clamps low, and that is kept: a channel's first key is the ;; pose the part starts in. Having NO value is a different question — it is a diff --git a/frontend/test/arthur/domain/symbol_test.cljs b/frontend/test/arthur/domain/symbol_test.cljs index 83cec43..6aa21ad 100644 --- a/frontend/test/arthur/domain/symbol_test.cljs +++ b/frontend/test/arthur/domain/symbol_test.cljs @@ -3,7 +3,8 @@ [arthur.demo.stage :as stage] [arthur.domain.channel :as ch] [arthur.domain.clip :as clip] - [arthur.domain.leaf :as leaf])) + [arthur.domain.leaf :as leaf] + [arthur.domain.node :as node])) (def source {:name "source" :fps 30 :width 320 :height 200 @@ -45,7 +46,28 @@ (is (= #{:main :sym/face-8625} (set (keys (:timelines document))))) (is (= :sym/face-8625 (get-in document [:timelines :main :nodes :left :of]))) (is (= :sym/face-8625 (get-in document [:timelines :main :nodes :right :of]))) + (is (= 7 (count (filter #(= :symbol (:kind %)) + (vals (get-in document [:timelines :main :nodes])))))) (is (= [48 280] (get-in document [:timelines :main :nodes :right :span]))) + (let [scale (get-in document [:timelines :main :nodes :left + :channels [:xform :scale]]) + anchor (get-in document [:timelines :main :nodes :left + :channels [:xform :anchor] :value]) + pos (get-in document [:timelines :main :nodes :left + :channels [:xform :pos]]) + start-pos (ch/value-at pos 0)] + (is (= [160 100] anchor) "the source center becomes a stored pivot") + (is (= [-120 -60] start-pos)) + (is (not= start-pos (ch/value-at pos 40)) "the face drifts during playback") + (is (= [0.4 0.4] (ch/value-at scale 0))) + (is (= [0.56 0.56] (ch/value-at scale 12))) + (is (= [0.52 0.52] (ch/value-at scale 48))) + (doseq [f [0 12 48]] + (let [m (node/local! (node/mat) start-pos 0 (ch/value-at scale f) [0 0] anchor) + out (js/Float64Array. 2)] + (node/apply-pt! out 0 m 160 100) + (is (= [40 40] [(aget out 0) (aget out 1)]) + "the face center stays put while it scales")))) (is (= :right (get-in document [:timelines :main :nodes :voice-right :linked-to]))) (is (= [48 260] (get-in document [:timelines :main :nodes :voice-right :span]))) (is (= 0.5 (ch/value-at