An instance's span is in its own frames

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 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 12:53:21 -04:00
parent 2d2eb0fc9f
commit 6b41c6db94
10 changed files with 103 additions and 42 deletions

View file

@ -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

View file

@ -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]))

View file

@ -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))]

View file

@ -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}]}

View file

@ -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

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

@ -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?

View file

@ -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]))]