Animate seven stage symbols and bound playback memory

This commit is contained in:
Olive Vaughn 2026-09-28 15:29:31 -04:00
parent ffb95543a3
commit 39ee37db02
8 changed files with 126 additions and 27 deletions

View file

@ -44,10 +44,13 @@
`:head` written as a dense track in one and as framed identity in the other, so `: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 the button that switches between them switches a document field and nothing
else." else."
{:demo (entry :demo "demo" demo/clip nil) {:demo {:label "demo" :entry (delay (entry :demo "demo" demo/clip nil))}
:swarm (entry :swarm "swarm" @swarm/clip @swarm/store) :swarm {:label "swarm" :entry (delay (entry :swarm "swarm" @swarm/clip @swarm/store))}
:take (entry :take "take" @take/clip @take/store) :take {:label "take" :entry (delay (entry :take "take" @take/clip @take/store))}
:take-locked (entry :take-locked "locked" @take/locked @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 (def default
{;; --- the document --- {;; --- the document ---
@ -60,7 +63,7 @@
;; footage's. That is what deleting `makeXform` buys — the framing became a ;; 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 ;; transform on a node, so nothing downstream of the freeze knows the frame
;; size — and it is why ui/player no longer hardcodes 320x200. ;; 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 ;; 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 ;; comes from the server — tier 3 is the backend's since step 9 — so there is

View file

@ -1,21 +1,41 @@
(ns arthur.demo.stage (ns arthur.demo.stage
"A saved take placed twice on a stage. The EDN is the authored layout." "A saved take placed seven times on a stage. The EDN is the authored layout."
(:require [cljs.reader :as reader] (:require [arthur.domain.channel :as ch]
[cljs.reader :as reader]
[shadow.resource :as rc])) [shadow.resource :as rc]))
(def layout (reader/read-string (rc/inline "arthur/demo/stage_8625.edn"))) (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] (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]) original (get-in source [:timelines :main])
nodes (into nodes (into
{:root {:id :root :name "stage" :kind :group :z "a1"}} {:root {:id :root :name "stage" :kind :group :z "a1"}}
(map (fn [{:keys [id name z span at in pos scale]}] (map (fn [{:keys [id name z span at in center anchor drift phase]}]
[id {:id id :name name :kind :symbol :of symbol (let [anchor (or anchor default-anchor)]
:parent :root :z z :span span [id {:id id :name name :kind :symbol :of symbol
:time {:mode :map :at at :in in :rate 1} :parent :root :z z :span span
:channels {[:xform :pos] {:animated? false :value pos} :time {:mode :map :at at :in in :rate 1}
[:xform :scale] {:animated? false :value scale}}}]) :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)) instances))
nodes (into nodes nodes (into nodes
(map (fn [{:keys [id linked-to z source span at in gain pan]}] (map (fn [{:keys [id linked-to z source span at in gain pan]}]

View file

@ -1,11 +1,22 @@
;; A local stage sketch. The source is the saved, post-processed IMG_8625.MOV ;; 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 ;; clip in the project store; its dense channel blocks are shared by all seven
;; instances. Positions and timing are authored here in stage pixels and frames. ;; instances. Centers, drift and timing are authored in stage pixels and frames.
{:source-project "4379f900-bdd2-409b-acf6-32081f8ce01f" {:source-project "4379f900-bdd2-409b-acf6-32081f8ce01f"
:source-cid "f8cace9e-4ad3-4796-973c-c62eeebe3d01" :source-cid "f8cace9e-4ad3-4796-973c-c62eeebe3d01"
:symbol :sym/face-8625 :symbol :sym/face-8625
:name "8625 stage study" :name "8625 stage study"
:width 320 :height 200 :frames 280 :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. ;; Audio placements are ordinary timeline nodes with channel parameters.
;; :linked-to is an editorial link; their spans and time maps are independent. ;; :linked-to is an editorial link; their spans and time maps are independent.
:audio :audio
@ -26,7 +37,22 @@
:instances :instances
[{:id :left :name "8625 left" :z "a1" [{:id :left :name "8625 left" :z "a1"
:span [0 280] :at 0 :in 0 :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" {:id :right :name "8625 right" :z "a2"
:span [48 280] :at 48 :in 0 :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}]}

View file

@ -172,8 +172,11 @@
(defn- interpolate [ch f left right] (defn- interpolate [ch f left right]
(let [a (get (:keys ch) left)] (let [a (get (:keys ch) left)]
(if (and (= :linear (:interp ch)) right (>= f left) (> right left)) (if (and (= :linear (:interp ch)) right (>= f left) (> right left))
(let [b (get (:keys ch) right)] (let [b (get (:keys ch) right)
(+ a (* (/ (- f left) (- right left)) (- b a)))) t (/ (- f left) (- right left))]
(if (vector? a)
(mapv (fn [x y] (+ x (* t (- y x)))) a b)
(+ a (* t (- b a)))))
a))) a)))
(defn- keyed-at (defn- keyed-at
@ -291,7 +294,16 @@
(defn problems (defn problems
"Human-readable reasons this map is not a channel. Empty means it is one." "Human-readable reasons this map is not a channel. Empty means it is one."
[ch] [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)) (not (map? ch))
(conj "not a map") (conj "not a map")
@ -315,8 +327,8 @@
(conj (str ":interp " (:interp ch) " is not implemented")) (conj (str ":interp " (:interp ch) " is not implemented"))
(and (map? ch) (= :linear (:interp ch)) (and (map? ch) (= :linear (:interp ch))
(or (:dense ch) (not (every? number? (vals (:keys ch)))))) (or (:dense ch) (not linear-values?)))
(conj ":linear interpolation needs numeric keyed values") (conj ":linear interpolation needs numeric keys of one shape")
(and (map? ch) (seq (:over ch))) (and (map? ch) (seq (:over ch)))
(conj ":over layers are not implemented (port-plan step 2 scope)") (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)))))) (not (and (number? (:scale (:dense ch))) (pos? (:scale (:dense ch))))))
(conj (str ":dense :scale is " (pr-str (:scale (:dense ch))) (conj (str ":dense :scale is " (pr-str (:scale (:dense ch)))
" — a fixed-point scale is a positive number the stored integers" " — a fixed-point scale is a positive number the stored integers"
" were multiplied by")))) " were multiplied by")))))

View file

@ -29,4 +29,4 @@
(defn entry [id] (defn entry [id]
(if (= id (:id @loaded)) (if (= id (:id @loaded))
@loaded @loaded
(get db/clips id))) (db/clip-entry id)))

View file

@ -151,7 +151,14 @@
(js/performance.mark "arthur/blit:start") (js/performance.mark "arthur/blit:start")
(canvas/blit! canvas ras ramp)) (canvas/blit! canvas ras ramp))
(js/performance.measure "arthur/resolve+draw" "arthur/paint:start" "arthur/blit:start") (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 (defonce ^:private watch
;; A scene swap changes the resolver and not the frame number, so the loop ;; A scene swap changes the resolver and not the frame number, so the loop

View file

@ -21,6 +21,15 @@
(is (= [:a :a :a :a :b :b :b :b :b :b :b :b :c :c] (is (= [:a :a :a :a :b :b :b :b :b :b :b :b :c :c]
(mapv #(ch/value-at c %) (range 0 14)))))) (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 (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 ;; 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 ;; pose the part starts in. Having NO value is a different question — it is a

View file

@ -3,7 +3,8 @@
[arthur.demo.stage :as stage] [arthur.demo.stage :as stage]
[arthur.domain.channel :as ch] [arthur.domain.channel :as ch]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.leaf :as leaf])) [arthur.domain.leaf :as leaf]
[arthur.domain.node :as node]))
(def source (def source
{:name "source" :fps 30 :width 320 :height 200 {:name "source" :fps 30 :width 320 :height 200
@ -45,7 +46,28 @@
(is (= #{:main :sym/face-8625} (set (keys (:timelines document))))) (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 :left :of])))
(is (= :sym/face-8625 (get-in document [:timelines :main :nodes :right :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]))) (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 (= :right (get-in document [:timelines :main :nodes :voice-right :linked-to])))
(is (= [48 260] (get-in document [:timelines :main :nodes :voice-right :span]))) (is (= [48 260] (get-in document [:timelines :main :nodes :voice-right :span])))
(is (= 0.5 (ch/value-at (is (= 0.5 (ch/value-at