From 6b41c6db948d0d248ca1b486201ed0b2a7a042b7 Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Tue, 29 Sep 2026 12:53:21 -0400 Subject: [PATCH] An instance's span is in its own frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shape's :span stays in its parent's frames, but an instance's or a sound's is now in its own: dropping a symbol at frame 97 gives it span 0 … length and :at 97, so moving it along its parent is one write to :at. :time :in is gone (it was the span's start written twice); node/placed-span maps an own-time span out to the parent for playback, the mixer and the timeline rows, and node/problems reports a stale :in. Co-Authored-By: Claude Opus 5.5 --- docs/animation-model.md | 6 ++++ frontend/src/arthur/audio/mix.cljs | 2 +- frontend/src/arthur/demo/stage.cljs | 8 ++--- frontend/src/arthur/demo/stage_8625.edn | 20 ++++++----- frontend/src/arthur/domain/clip.cljs | 11 +++--- frontend/src/arthur/domain/node.cljs | 36 ++++++++++++++++--- frontend/src/arthur/domain/symbol.cljs | 7 ++-- frontend/src/arthur/ui/params.cljs | 6 +++- frontend/src/arthur/ui/timeline.cljs | 24 ++++++++----- .../test/arthur/domain/instance_test.cljs | 25 +++++++++---- 10 files changed, 103 insertions(+), 42 deletions(-) diff --git a/docs/animation-model.md b/docs/animation-model.md index a8393cf..a8ebb3d 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -79,6 +79,12 @@ this way. over which the node exists at all. Distinct from a `[:vis]` channel, which blinks an existing node on and off. +A shape's `:span` is in its parent's frames. An **instance's** (or a sound's) +`:span` is in its **own** frames — which part of what it places plays, from its +own 0 — and `:time :at` is where that first frame lands in the parent. Moving an +instance along its parent changes `:at` and nothing else; `node/placed-span` is +the one function that maps an own-time span out to the parent. + ### Subjects and tracked features Scene nodes describe drawings, not tracking identity. A scene may also carry a diff --git a/frontend/src/arthur/audio/mix.cljs b/frontend/src/arthur/audio/mix.cljs index 98dea64..a9e040e 100644 --- a/frontend/src/arthur/audio/mix.cljs +++ b/frontend/src/arthur/audio/mix.cljs @@ -103,7 +103,7 @@ output (js/OfflineAudioContext. 2 (js/Math.ceil (* (/ frames fps) 44100)) 44100)] (doseq [track tracks] - (let [[start end] (or (:span track) [0 frames]) + (let [[start end] (or (node/placed-span track) [0 frames]) start (max 0 start) end (min frames end) {:keys [buffer fps]} (get sources (get-in track [:source :footage])) diff --git a/frontend/src/arthur/demo/stage.cljs b/frontend/src/arthur/demo/stage.cljs index b0645f8..35e0ade 100644 --- a/frontend/src/arthur/demo/stage.cljs +++ b/frontend/src/arthur/demo/stage.cljs @@ -47,11 +47,11 @@ :known (vec (sort-by str (keys by-id)))})))) nodes (into {:root {:id :root :name "stage" :kind :group :z "a1"}} - (map (fn [{:keys [uuid name z span at in center anchor drift phase]}] + (map (fn [{:keys [uuid name z span at center anchor drift phase]}] (let [anchor (or anchor default-anchor)] [uuid {:id uuid :name name :kind :instance :of symbol :parent :root :z z :span span - :time {:mode :map :at at :in in :rate 1} + :time {:mode :map :at at :rate 1} :channels {[:xform :pos] (if drift (position-track center anchor drift phase frames) (ch/framed (mapv - center anchor))) @@ -59,11 +59,11 @@ [:xform :scale] scale}}])) instances)) nodes (into nodes - (map (fn [{:keys [uuid linked-to z source span at in gain pan]}] + (map (fn [{:keys [uuid linked-to z source span at gain pan]}] [uuid {:id uuid :kind :audio :parent :root :z z :linked-to (uuid-of uuid linked-to) :source source :span span - :time {:mode :map :at at :in in :rate 1} + :time {:mode :map :at at :rate 1} :channels (cond-> {[:audio :gain] gain} pan (assoc [:audio :pan] pan))}]) audio))] diff --git a/frontend/src/arthur/demo/stage_8625.edn b/frontend/src/arthur/demo/stage_8625.edn index e75c21e..a1ecf64 100644 --- a/frontend/src/arthur/demo/stage_8625.edn +++ b/frontend/src/arthur/demo/stage_8625.edn @@ -19,16 +19,18 @@ :over []} ;; Audio placements are ordinary timeline nodes with channel parameters. ;; :linked-to is an editorial link; their spans and time maps are independent. + ;; A span is in the placement's OWN frames and :at is where its frame 0 lands on + ;; the stage, so every entrance below plays from its own start. :audio [{:id :voice-left :uuid #uuid "eeaa49c3-1238-469f-bf54-44929e379f6b" :linked-to :left :z "a3" :source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"} - :span [0 280] :at 0 :in 0 + :at 0 :span [0 280] :gain {:animated? false :value 1.0}} {:id :voice-right :uuid #uuid "468239dd-0e3a-4e5c-ac6f-1858430a0355" :linked-to :right :z "a4" :source {:footage "f8cace9e-4ad3-4796-973c-c62eeebe3d01"} - :span [48 260] :at 48 :in 0 + :at 48 :span [0 212] :gain {:animated? true :interp :linear :keys {48 0.0, 60 1.0, 90 0.35, 115 0.9, 145 0.45, 170 1.0, 195 0.4, 220 0.85, 245 1.0, 259 0.0} @@ -49,29 +51,29 @@ :instances [{:id :left :uuid #uuid "ee7321c8-faf1-46d7-8029-37771898accb" :name "8625 left" :z "a1" - :span [0 280] :at 0 :in 0 + :at 0 :span [0 280] :center [40 40] :drift [3 2] :phase 0} {:id :right :uuid #uuid "1aa0da78-b4ed-4bb6-8d70-b09a3ec5e2c3" :name "8625 right" :z "a2" - :span [48 280] :at 48 :in 0 + :at 48 :span [0 232] :center [120 40] :drift [-3 2] :phase 17} {:id :top-third :uuid #uuid "23bb697d-eba7-4af6-a86c-606c50107088" :name "8625 top third" :z "a5" - :span [24 280] :at 24 :in 0 + :at 24 :span [0 256] :center [200 40] :drift [2 -3] :phase 31} {:id :top-fourth :uuid #uuid "f4f0241d-026e-4e50-9bea-a4ccde896d8a" :name "8625 top fourth" :z "a6" - :span [72 280] :at 72 :in 0 + :at 72 :span [0 208] :center [280 40] :drift [-2 -2] :phase 49} {:id :bottom-left :uuid #uuid "8f594d72-a97f-4a32-82fd-08d1670a2218" :name "8625 bottom left" :z "a7" - :span [96 280] :at 96 :in 0 + :at 96 :span [0 184] :center [70 135] :drift [3 -2] :phase 63} {:id :bottom-middle :uuid #uuid "63f3fb32-9e94-4d68-a1c2-12e6de2d04b5" :name "8625 bottom middle" :z "a8" - :span [120 280] :at 120 :in 0 + :at 120 :span [0 160] :center [160 135] :drift [-2 3] :phase 81} {:id :bottom-right :uuid #uuid "fa338701-cb21-4d45-89f1-a5e706f045ec" :name "8625 bottom right" :z "a9" - :span [144 280] :at 144 :in 0 + :at 144 :span [0 136] :center [250 135] :drift [2 2] :phase 107}]} diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 8681331..f5d6a98 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -129,9 +129,10 @@ generating one in here would make this function's result depend on when it was called, and this namespace is the pure one. - The instance's own time starts where it was dropped: `:at frame` with `:in 0` - means local frame 0 of the symbol plays on `frame` of `host`, which is what - dragging something onto a playhead is asking for. + The instance's own time starts where it was dropped: `:at frame` means frame 0 + of the symbol plays on `frame` of `host`, which is what dragging something onto + a playhead is asking for. Its `:span` is in its OWN frames — the whole symbol, + 0 to its length — wherever it was dropped; see `node/placed-span`. Refused, returning the clip unchanged, when it would make a cycle: a symbol cannot be placed inside itself or inside anything it places." @@ -151,8 +152,8 @@ ;; Lexicographic draw order, as `domain/paint` does it: a placement made ;; later sits above one made earlier, and neither has to renumber. :z (str "z" (js/Date.now) "-" (name sid)) - :span [frame (min end (+ frame (:frames target)))] - :time {:mode :map :at frame :in 0 :rate 1} + :span [0 (:frames target)] + :time {:mode :map :at frame :rate 1} :channels {[:xform :pos] {:animated? false :value [x y]}}})))) (defn fresh-id diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index a4ffb07..673233b 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -103,6 +103,28 @@ (/ source-fps picture-fps)))) f)) +(def timed-kinds + "Kinds that place something with a time of its own — a symbol, a sound — and + so read their `:span` in THAT time rather than in their parent's." + #{:instance :audio}) + +(defn placed-span + "Where a node exists, as `[in out)` in its PARENT's frames, or nil for always. + + TWO MEANINGS OF `:span`, and this is the one place that knows both. A shape has + no time of its own, so its `:span` is already in its parent's frames. An + instance or a sound HAS a time of its own, and its `:span` is in that time: which + frames of what it places are played, from its own 0 however late it enters. Where + that first frame lands in the parent is `:at`, a separate fact — so moving an + instance along its parent is one write to `:at`, and the span, which says what + the instance IS, does not change when it is moved." + [n] + (when-let [[in out] (:span n)] + (if (timed-kinds (:kind n)) + (let [{:keys [at rate] :or {at 0 rate 1}} (:time n)] + [at (+ at (/ (- out in) rate))]) + [in out]))) + (defn local-frame "Apply a node's time map to the frame it was handed by its parent. @@ -117,19 +139,21 @@ as two performances; offset is PER-NODE by design, because mouth lead applies to performance nodes and not to the plate, which is the entire point of it." [n f] - (let [{:keys [mode offset rate at in source-fps sample-fps] + (let [{:keys [mode offset rate at source-fps sample-fps] ex :expose :or {mode :inherit}} (:time n)] (if (= mode :inherit) f (do - (when (and (not (#{:instance :audio} (:kind n))) rate (not= rate 1.0) (not= rate 1)) + (when (and (not (timed-kinds (:kind n))) rate (not= rate 1.0) (not= rate 1)) (throw (ex-info "time map :rate belongs to an instance or an audio node" {:node (:id n) :time (:time n)}))) (when (and sample-fps (not (and source-fps (pos? source-fps)))) (throw (ex-info "picture sampling needs a positive source fps" {:node (:id n) :time (:time n)}))) - (cond-> (if (#{:instance :audio} (:kind n)) - (+ (or in 0) (* (or rate 1) (- f (or at 0)))) + (cond-> (if (timed-kinds (:kind n)) + ;; `:at` is where the span's first frame lands, so the parent's + ;; `at` is this node's `in`. + (+ (or (first (:span n)) 0) (* (or rate 1) (- f (or at 0)))) f) sample-fps (sample-frame source-fps sample-fps) ex (expose ex) @@ -273,7 +297,9 @@ (conj "an instance's :rate must be positive") (nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree") (and (:span n) (not= 2 (count (:span n)))) - (conj ":span must be [in out]")) + (conj ":span must be [in out]") + (some? (get-in n [:time :in])) + (conj ":time has an :in — an instance's first frame is the start of its own :span")) (into (when valid (for [[path _] (:channels n) diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index 7b2d49f..54e6472 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -166,10 +166,11 @@ (defn- in-span? "`:span` is Lottie's ip/op and Flash's PlaceObject/RemoveObject: the range over which the node EXISTS, tested in the PARENT's frame space and therefore before - the node's own time map runs. Distinct from `[:vis]`, which blinks an existing - node on and off. Half-open, so two adjacent spans do not both own a frame." + the node's own time map runs — an instance's own-time span is mapped out by + `node/placed-span`. Distinct from `[:vis]`, which blinks an existing node on and + off. Half-open, so two adjacent spans do not both own a frame." [n f] - (if-let [[in out] (:span n)] + (if-let [[in out] (node/placed-span n)] (and (>= f in) (< f out)) true)) diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index e4a2f27..123f232 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -126,7 +126,11 @@ ;; Which symbol an instance places. The one fact that makes an instance ;; legible as an instance rather than as a node. "of" (when (= :instance (:kind n)) (str (:of n))) - "span" (when start (str start " … " end))] + ;; An instance's span is in its OWN frames and `at` is where it sits in + ;; this symbol; a shape's span is in this symbol's frames. See + ;; `node/placed-span`. + "span" (when start (str start " … " end)) + "at" (when (node/timed-kinds (:kind n)) (str (get-in n [:time :at] 0)))] (when (:paint? n) [drawing-keys sid id n frame]) [:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]] [:dl.facts diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 26f899b..d8c2f75 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -49,12 +49,13 @@ frame the exposure grid never samples is still authored on that frame, and that is where the row should show it." [n] - (let [{:keys [mode offset at in rate] :or {mode :inherit at 0 in 0 rate 1}} (:time n)] + (let [{:keys [mode offset at rate] :or {mode :inherit at 0 rate 1}} (:time n) + in (or (first (:span n)) 0)] (if (= mode :inherit) identity (fn [f] (let [f (- f (or offset 0))] - (if (and (#{:instance :audio} (:kind n)) (not (zero? rate))) + (if (and (node/timed-kinds (:kind n)) (not (zero? rate))) (js/Math.round (+ at (/ (- f in) rate))) f)))))) @@ -107,7 +108,12 @@ ;; child at this node's local frame — so the nested ;; walk carries it down unchanged. self (comp ->open (local->parent n)) - span (mapv ->open (or (:span n) [0 (:frames sym)])) + span (mapv ->open + (or (node/placed-span + (cond-> n + (and (= :instance (:kind n)) (nil? (:span n))) + (assoc :span [0 (get-in clip [:symbols (:of n) :frames])]))) + [0 (:frames sym)])) row {:path rpath :depth depth :label (node-label id n) @@ -205,11 +211,13 @@ (defn- track-cell [{:keys [span keys dense?]} frames] [:div.tl-track - (when span - [:div {:class (str "tl-span" (when dense? " dense")) - :style {:left (edge% (first span) frames) - :width (str (* 100 (/ (- (second span) (first span)) - (max 1 frames))) "%")}}]) + ;; Clipped to the ruler: an instance longer than the room left in its + ;; symbol still plays its own frames from 0, it is just cut off at the end. + (when-let [[in out] (when span [(max 0 (first span)) (min frames (second span))])] + (when (< in out) + [:div {:class (str "tl-span" (when dense? " dense")) + :style {:left (edge% in frames) + :width (str (* 100 (/ (- out in) (max 1 frames))) "%")}}])) ;; A dense channel has a value on every frame, so ticking each one is a solid ;; block that says less than the bar behind it already does. (when-not dense? diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index d58b792..791c07c 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -30,8 +30,8 @@ :parent :root :z "a1" :span [0 4] :channels {[:xform :pos] (ch/framed [100 50])}} :right {:id :right :kind :instance :of :sym/test - :parent :root :z "a2" :span [2 6] - :time {:mode :map :at 2 :in 0 :rate 1} + :parent :root :z "a2" :span [0 4] + :time {:mode :map :at 2 :rate 1} :channels {[:xform :pos] (ch/framed [120 50])}}}}) (assoc-in [:symbols :sym/test] (assoc (get-in source [:symbols :main]) :id :sym/test))) @@ -171,7 +171,8 @@ (is (= 7 (count (distinct (map #(:name (val %)) symbols)))))))) (is (= 7 (count (filter #(= :instance (:kind %)) (vals (get-in document [:symbols :main :nodes])))))) - (is (= [48 280] (:span (placement document :right)))) + (is (= [0 232] (:span (placement document :right))) "its own frames, from its own 0") + (is (= [48 280] (node/placed-span (placement document :right))) "and where that sits on the stage") (let [left (placement document :left) scale (get-in left [:channels [:xform :scale]]) anchor (get-in left [:channels [:xform :anchor] :value]) @@ -195,7 +196,7 @@ ;; resolves to a node at all; this checks it resolves to the RIGHT one. (is (= (uuid-of :right) (:linked-to (placement document :voice-right)))) (is (uuid? (:linked-to (placement document :voice-right))))) - (is (= [48 260] (:span (placement document :voice-right)))) + (is (= [48 260] (node/placed-span (placement document :voice-right)))) (is (= 0.5 (ch/value-at (get-in (placement document :voice-right) [:channels [:audio :gain]]) 54))) @@ -222,7 +223,8 @@ (testing "an instance can go into any symbol, and spans that symbol's frames" (let [[n] (vals (get-in c [:symbols :outer :nodes]))] (is (= :inner (:of n))) - (is (= [5 15] (:span n)) "as long as what it places, not as the space it is in"))) + (is (= [0 10] (:span n)) "its own frames: all of what it places, from its own 0") + (is (= [5 15] (node/placed-span n)) "and where that lands in the symbol it is in"))) (testing "placing is refused when it would make a cycle" (is (clip/contains-symbol? c :outer :inner)) (is (not (clip/contains-symbol? c :inner :outer))) @@ -245,12 +247,23 @@ (is (= {:id :symbol-1 :name "symbol-1" :frames 180 :nodes {}} (clip/symbol made :symbol-1)) "empty, and as long as the rest of what it was placed in") - (is (= {:of :symbol-1 :span [20 200] :time {:mode :map :at 20 :in 0 :rate 1}} + (is (= {:of :symbol-1 :span [0 180] :time {:mode :map :at 20 :rate 1}} (select-keys (get-in made [:symbols :outer :nodes u]) [:of :span :time]))) (is (empty? (clip/problems made))) (is (= c (clip/new-symbol c :outer :inner 0 u)) "an id already in use is refused") (is (= c (clip/new-symbol c :outer id 200 u)) "past the end is refused"))) +(deftest an-instance-span-is-in-its-own-frames + (let [n {:id :i :kind :instance :of :x :z "a1" :span [3 13] + :time {:mode :map :at 40 :rate 2}}] + (is (= [40 45] (node/placed-span n)) "ten own frames at double rate is five") + (is (= 3 (node/local-frame n 40)) "the parent's :at is the span's first frame") + (is (= 11 (node/local-frame n 44))) + (is (= [2 9] (node/placed-span {:kind :poly :span [2 9]})) + "a shape has no time of its own, so its span is already the parent's") + (is (seq (node/problems (assoc-in n [:time :in] 3))) + "a stale :in is reported rather than silently ignored"))) + (deftest a-frame-is-carried-down-through-the-instances-a-row-path-names (let [c (nested) [id] (keys (get-in c [:symbols :outer :nodes]))]