One time map for every node; move and group nodes between symbols

Every node now has the same time map into its parent, local = rate·(parent
− at), with its span and keys in its own frames: what instances had, made the
rule. A shape without a time map reads as it always did, so no data changes.
The per-kind branches, the span-start term and the rate refusal are gone;
node/time-of, then-time and invert-time compose it like the matrix.

clip/move-node puts a node into another symbol without changing the picture
or the timing — its matrix becomes a :pinv, its time a new :at and :rate, and
its channels, keys and span are untouched — and clip/group makes a new symbol
around side-by-side nodes. Generated parts, split stencils, cycles and
looping instances are refused with the reason. Nested sounds use the same
map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Olive Vaughn 2026-09-29 13:45:34 -04:00
parent 6bec121108
commit eafbe6c4d2
7 changed files with 360 additions and 91 deletions

View file

@ -79,11 +79,19 @@ this way.
over which the node exists at all. Distinct from a `[:vis]` channel, which over which the node exists at all. Distinct from a `[:vis]` channel, which
blinks an existing node on and off. blinks an existing node on and off.
A shape's `:span` is in its parent's frames. An **instance's** (or a sound's) **Every node has the same two maps into its parent**, whatever kind it is:
`: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 - **space** — the matrix its transform channels compose to, times a `:pinv` if
instance along its parent changes `:at` and nothing else; `node/placed-span` is it has been moved in from elsewhere;
the one function that maps an own-time span out to the parent. - **time** — `local = rate · (parent − at)`, from `:time :at` and `:rate`,
identity when absent. `:span` and every key are in the node's **own** frames.
A move keeps a node's world maps and re-expresses them under its new parent:
the matrix becomes a `:pinv`, the time becomes a new `:at` and `:rate`, and its
channels, keys and span are not touched. Both maps are affine, so any depth of
nesting is one map and every move is one inverse. `node/time-of`,
`node/then-time` and `node/placed-span` are the time half; `clip/move-node` and
`clip/group` are the move.
### Subjects and tracked features ### Subjects and tracked features

View file

@ -293,7 +293,8 @@
(assoc-in [:symbols sid :nodes :sound] (assoc-in [:symbols sid :nodes :sound]
{:id :sound :name "sound" :kind :audio :parent nil {:id :sound :name "sound" :kind :audio :parent nil
:z "z-sound" :source {:footage footage-id} :z "z-sound" :source {:footage footage-id}
:span range :time {:mode :map :at 0 :rate 1}})) ;; Source frame `start` plays on the symbol's 0.
:span range :time {:mode :map :at (- (first range)) :rate 1}}))
tracked? (-> (assoc :analysis (:analysis take)) tracked? (-> (assoc :analysis (:analysis take))
(update :subjects merge (:subjects take)) (update :subjects merge (:subjects take))
(update :features merge (:features take)) (update :features merge (:features take))
@ -304,31 +305,29 @@
and, recursively, those inside the instances it places. and, recursively, those inside the instances it places.
A sound inside a placed symbol is heard where the instance puts it, so each one A sound inside a placed symbol is heard where the instance puts it, so each one
is carried OUT through the instance's time map — `:at` and `:rate`, and clipped is carried OUT through the instance's time map — the same map a timeline row
to the instance's own span — until it is in the frames of the symbol being draws with — and cut to the instance's own span, until it is in the frames of
played. Keyed automation moves with it. What comes back is what a mixer that the symbol being played. Keyed automation moves with it. What comes back is
only knows flat tracks can play as it is." what a mixer that only knows flat tracks can play as it is."
[clip sid] [clip sid]
(let [sym (symbol clip sid)] (let [sym (symbol clip sid)]
(into (vec (filter #(= :audio (:kind %)) (vals (:nodes sym)))) (into (vec (filter #(= :audio (:kind %)) (vals (:nodes sym))))
(mapcat (mapcat
(fn [inst] (fn [inst]
(let [{:keys [mode at rate] :or {at 0 rate 1}} (:time inst) (let [outer (node/time-of inst)
inner (symbol clip (:of inst)) ->outer (fn [x] (+ (:at outer) (/ x (:rate outer))))
[in out] (or (:span inst) [0 (:frames inner)]) [in out] (or (:span inst) [0 (frames clip (:of inst))])]
in (if (= mode :map) in 0)
->outer (fn [x] (+ at (/ (- x in) rate)))]
(keep (fn [a] (keep (fn [a]
(let [{a-at :at a-rate :rate :or {a-at 0 a-rate 1}} (:time a) (let [[p0 p1] (or (node/placed-span a) [in out])
[s0 s1] (or (:span a) [0 (:frames inner)]) x0 (max p0 in)
x0 (max a-at in) x1 (min p1 out)
x1 (min (+ a-at (/ (- s1 s0) a-rate)) out)] own (node/time-of a)
->own (fn [x] (* (:rate own) (- x (:at own))))
world (node/then-time outer own)]
(when (< x0 x1) (when (< x0 x1)
(-> a (-> a
(assoc :span [(+ s0 (* a-rate (- x0 a-at))) (assoc :span [(->own x0) (->own x1)]
(+ s0 (* a-rate (- x1 a-at)))] :time {:mode :map :at (:at world) :rate (:rate world)})
:time {:mode :map :at (->outer x0)
:rate (* a-rate rate)})
(update :channels (update :channels
(fn [chs] (fn [chs]
(into {} (map (fn [[p ch]] (into {} (map (fn [[p ch]]
@ -387,6 +386,177 @@
[(aget out 0) (aget out 1)])) [(aget out 0) (aget out 1)]))
(partition 2 pts))))))) (partition 2 pts)))))))
;; ---------------------------------------------------------------------------
;; reparenting
;;
;; ONE NESTING, AS FAR AS A PERSON IS CONCERNED. Putting a node inside another
;; symbol is how things are grouped: the symbol is a shared timeline, and its
;; instance is the handle that moves, retimes and transforms everything in it
;; together. Parent pointers inside a symbol stay — the roto rig is built on them
;; — but they are not something the timeline hands out.
;;
;; A MOVE CHANGES NEITHER THE PICTURE NOR THE TIMING. Space: the node keeps its
;; own channels and gains a `:pinv`, the matrix from where it was to where it
;; goes, which is Blender's parent-inverse and what `node/world!` has always
;; applied. Time: its frames shift by the difference between the two symbols'
;; time maps — its keys and span if it is a shape, only its `:at` if it is an
;; instance, whose span is in its own frames and does not move at all.
(defn- time-down
"The time map from symbol `sid` down through the instances `path` names: the
frame of the last one's symbol for each frame of `sid`. Each step is the
instance's own map after its parents' in that symbol, so the map is the one
the stage reads with, floors aside. Nil through a looping instance, whose
frames come round again and do not map one to one."
[clip sid path]
(:down
(reduce (fn [{:keys [sid down]} id]
(let [nodes (:nodes (symbol clip sid))
chain (map #(get nodes %) (rseq (symbol/lineage nodes id)))]
(if (some #(get-in % [:time :loop?]) chain)
(reduced {:down nil})
{:sid (:of (get nodes id))
:down (reduce node/then-time down (map node/time-of chain))})))
{:sid sid :down {:at 0 :rate 1}}
path)))
(defn- retime
"Node `n` with its own time map replaced by `m`, and nothing else touched: its
span and keys are in its own frames, which a move does not change."
[n {:keys [at rate]}]
(assoc n :time (merge (:time n) {:mode :map :at at :rate rate :offset 0})))
(defn- subtree
"`id` and every node whose parent chain reaches it."
[nodes id]
(into #{} (filter #(some #{id} (symbol/lineage nodes %))) (keys nodes)))
(defn- transplant
"Move node `id` from symbol `host`, where frame `frame` is showing, into symbol
`target`, keeping where it is on screen and when. `carry` is the matrix from
`host`'s coordinates to `target`'s, and `back` the time map from `target`'s
frames to `host`'s.
THE ONE RULE, for space and time alike: the node's new map is its old one
under what it leaves — its parents here, and the way from here to there — so
the picture and the timing through it do not change. For space that is a
`:pinv`, Blender's parent-inverse; for time it is a new `:at` and `:rate`. Its
channels, keys and span are untouched, and its children keep their parent
pointers and come with it. `{:clip}` or `{:refused why}`."
[clip store host frame id target carry back]
(let [nodes (:nodes (symbol clip host))
n (get nodes id)
moving (subtree nodes id)
;; Its parents in this symbol, outermost first.
chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id))))
parent (when-let [p (:parent n)]
(let [r (symbol/resolver (symbol clip host) store pal/index-of nil
{:source-fps (:fps clip)})]
(r frame)
(some-> (symbol/world-of r p) js/Float64Array.from)))]
(cond
(= host target) {:refused "it is already there"}
(and (= :instance (:kind n)) (contains-symbol? clip (:of n) target))
{:refused "a symbol cannot go inside itself"}
(some (fn [m] (or (:measured (get nodes m))
(some #(or (:dense %) (:generated %)) (vals (:channels (get nodes m))))))
moving)
{:refused "generated parts stay with their take — move the instance that places it"}
(some (fn [[k m]] (and (:stencil m)
(not= (contains? moving k) (contains? moving (:stencil m)))))
nodes)
{:refused "a stencil and what it clips have to move together"}
(and (:parent n) (nil? parent))
{:refused "its parent is not on screen at this frame"}
(some #(get-in % [:time :loop?]) chain)
{:refused "a looping parent is in the way"}
:else
(let [taken (:nodes (symbol clip target))
ids (into {} (map (fn [m] [m (free-id #(contains? taken %) m)])) moving)
pinv (reduce #(node/mul! (node/mat) %1 %2) carry (keep identity [parent (node/pinv n)]))
;; back · parents · own: target frames to the node's own.
time (reduce node/then-time back (concat (map node/time-of chain)
[(node/time-of n)]))
moved (for [m moving
:let [x (get nodes m)]]
(cond-> (-> x
(assoc :id (ids m))
(update :parent #(get ids %)))
(:stencil x) (update :stencil ids)
(= m id) (-> (retime time)
(assoc :pinv (vec (array-seq pinv))
:z (str "z" (js/Date.now) "-" (ids m))))))]
{:clip (-> clip
(update-symbol host update :nodes #(apply dissoc % moving))
(update-symbol target update :nodes (fnil into {})
(map (juxt :id identity)) moved))}))))
(defn move-node
"Move the node at row path `from` — its last id is the node, the rest the
instances down to where it lives — into the symbol placed by the instance at
row path `to`, or to the top of `open` when `to` is empty. Row paths start at
`open`, and `f` is its current frame, at which both have to be on screen.
`{:clip}`, or `{:refused why}`."
[clip store open from to f]
(let [here (inside clip store open (pop from) f)
there (inside clip store open to f)
a (time-down clip open (pop from))
b (time-down clip open to)
inv (some-> there :matrix invert)]
(cond
(nil? (get-in clip [:symbols (:sid here) :nodes (peek from)]))
{:refused "nothing to move"}
(or (nil? here) (nil? there)) {:refused "both have to be on screen at this frame"}
(not (and a b)) {:refused "a looping instance is in the way"}
(nil? inv) {:refused "the target is scaled to nothing"}
:else (transplant clip store (:sid here) (:frame here) (peek from) (:sid there)
(node/mul! (node/mat) inv (:matrix here))
(node/then-time (node/invert-time b) a)))))
(defn group
"Put the nodes at row paths `froms`, all side by side in one symbol, into a
NEW symbol `sid`, placed where they were by instance `uuid`. `{:clip}` or
`{:refused why}`.
The new symbol starts where the earliest of them starts and ends where the
last one ends, so its instance's bar on the timeline covers exactly theirs.
Its instance sits at the identity, so nothing moves, and pivots about the
middle of what it now holds."
[clip store open froms sid uuid f]
(let [host-path (pop (first froms))
{host :sid frame :frame} (inside clip store open host-path f)
nodes (:nodes (symbol clip host))
whole [0 (frames clip host)]
spans (for [from froms
:let [n (get nodes (peek from))]]
(or (node/placed-span n)
(when (= :instance (:kind n))
(node/placed-span (assoc n :span [0 (frames clip (:of n))])))
whole))
start (max 0 (apply min (map first spans)))
end (min (second whole) (apply max (map second spans)))]
(cond
(nil? host) {:refused "they have to be on screen at this frame"}
(not-every? #(= host-path (pop %)) froms) {:refused "only things side by side can be grouped"}
(some #(nil? (get nodes (peek %))) froms) {:refused "nothing to group"}
:else
(let [made (-> clip
(assoc-in [:symbols sid] {:id sid :name (name sid)
:frames (max 1 (js/Math.ceil (- end start)))
:nodes {}})
(place-symbol store host sid (js/Math.floor start) uuid nil))
moved (reduce (fn [acc from]
(let [r (transplant (:clip acc) store host frame (peek from) sid
(node/mat)
(node/invert-time
(node/time-of (get-in (:clip acc) [:symbols host :nodes uuid]))))]
(if (:refused r) (reduced r) r)))
{:clip made} froms)]
(cond-> moved
(:clip moved) (update :clip assoc-in
[:symbols host :nodes uuid :channels [:xform :anchor] :value]
(center (:clip moved) store sid)))))))
(defn- transform-op (defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space." "Put a symbol's already resolved mark into its instance's parent space."
[op m path] [op m path]

View file

@ -103,27 +103,42 @@
(/ source-fps picture-fps)))) (/ source-fps picture-fps))))
f)) f))
(def timed-kinds (defn time-of
"Kinds that place something with a time of its own — a symbol, a sound — and "A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame
so read their `:span` in THAT time rather than in their parent's." `p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A
#{:instance :audio}) node with no time map is `{:at 0 :rate 1}`, reading its parent's frames as its
own; a mouth lead's `:offset` is folded into `:at`. Exposure and picture
sampling are floors, not part of the map, and are left out: this is the map a
move preserves and a timeline row draws with, and `local-frame` is what reads
a frame, floors and the lead in their load-bearing order."
[n]
(let [{:keys [mode at rate offset] :or {at 0 rate 1 offset 0}} (:time n)]
(if (= mode :map)
{:at (- at (/ offset rate)) :rate rate}
{:at 0 :rate 1})))
(defn then-time
"`outer` then `inner`: the map from `outer`'s parent straight to `inner`'s own
frames. Time maps compose like matrices do, which is what makes a nesting of
any depth one map."
[{a1 :at r1 :rate} {a2 :at r2 :rate}]
{:at (+ a1 (/ a2 r1)) :rate (* r1 r2)})
(defn invert-time [{:keys [at rate]}]
{:at (- (* at rate)) :rate (/ 1 rate)})
(defn placed-span (defn placed-span
"Where a node exists, as `[in out)` in its PARENT's frames, or nil for always. "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 A `:span` is in the node's OWN frames — which of its frames exist — for every
no time of its own, so its `:span` is already in its parent's frames. An node alike, and its time map says where they land in the parent. For a node
instance or a sound HAS a time of its own, and its `:span` is in that time: which with no time map the two are the same frames, so a shape's span reads as it
frames of what it places are played, from its own 0 however late it enters. Where always did. Moving a node along its parent is then one write to `:at`, and the
that first frame lands in the parent is `:at`, a separate fact — so moving an span, which says what the node IS, does not change when it is moved."
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] [n]
(when-let [[in out] (:span n)] (when-let [[in out] (:span n)]
(if (timed-kinds (:kind n)) (let [{:keys [at rate]} (time-of n)]
(let [{:keys [at rate] :or {at 0 rate 1}} (:time n)] [(+ at (/ in rate)) (+ at (/ out rate))])))
[at (+ at (/ (- out in) rate))])
[in out])))
(defn local-frame (defn local-frame
"Apply a node's time map to the frame it was handed by its parent. "Apply a node's time map to the frame it was handed by its parent.
@ -139,25 +154,18 @@
as two performances; offset is PER-NODE by design, because mouth lead applies 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." to performance nodes and not to the plate, which is the entire point of it."
[n f] [n f]
(let [{:keys [mode offset rate at source-fps sample-fps] (let [{:keys [mode source-fps sample-fps] ex :expose :or {mode :inherit}} (:time n)]
ex :expose :or {mode :inherit}} (:time n)]
(if (= mode :inherit) (if (= mode :inherit)
f f
(do (do
(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)))) (when (and sample-fps (not (and source-fps (pos? source-fps))))
(throw (ex-info "picture sampling needs a positive source fps" (throw (ex-info "picture sampling needs a positive source fps"
{:node (:id n) :time (:time n)}))) {:node (:id n) :time (:time n)})))
(cond-> (if (timed-kinds (:kind n)) (let [{:keys [at rate offset] :or {at 0 rate 1}} (:time n)]
;; `:at` is where the span's first frame lands, so the parent's (cond-> (* rate (- f at))
;; `at` is this node's `in`. sample-fps (sample-frame source-fps sample-fps)
(+ (or (first (:span n)) 0) (* (or rate 1) (- f (or at 0)))) ex (expose ex)
f) offset (+ offset)))))))
sample-fps (sample-frame source-fps sample-fps)
ex (expose ex)
offset (+ offset))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; the transform ;; the transform
@ -292,9 +300,9 @@
(and (= k :instance) (nil? (:of n))) (conj "an instance needs :of") (and (= k :instance) (nil? (:of n))) (conj "an instance needs :of")
(and (= k :audio) (nil? (get-in n [:source :footage]))) (and (= k :audio) (nil? (get-in n [:source :footage])))
(conj "an audio instance needs :source :footage") (conj "an audio instance needs :source :footage")
(and (#{:instance :audio} k) (some? (get-in n [:time :rate])) (and (some? (get-in n [:time :rate]))
(not (pos? (get-in n [:time :rate])))) (not (pos? (get-in n [:time :rate]))))
(conj "an instance's :rate must be positive") (conj ":time :rate must be positive")
(nil? (:z n)) (conj "no :z — draw order is authored per scene, not implied by the tree") (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)))) (and (:span n) (not= 2 (count (:span n))))
(conj ":span must be [in out]") (conj ":span must be [in out]")

View file

@ -126,11 +126,10 @@
;; Which symbol an instance places. The one fact that makes an instance ;; Which symbol an instance places. The one fact that makes an instance
;; legible as an instance rather than as a node. ;; legible as an instance rather than as a node.
"of" (when (= :instance (:kind n)) (str (:of n))) "of" (when (= :instance (:kind n)) (str (:of n)))
;; An instance's span is in its OWN frames and `at` is where it sits in ;; A span is in the node's OWN frames and `at` is where its frame 0 sits
;; this symbol; a shape's span is in this symbol's frames. See ;; in this symbol. See `node/placed-span`.
;; `node/placed-span`.
"span" (when start (str start " … " end)) "span" (when start (str start " … " end))
"at" (when (node/timed-kinds (:kind n)) (str (get-in n [:time :at] 0)))] "at" (when (= :map (get-in n [:time :mode])) (str (get-in n [:time :at] 0)))]
(when (:paint? n) [drawing-keys sid id n frame]) (when (:paint? n) [drawing-keys sid id n frame])
[:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]] [:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]]
[:dl.facts [:dl.facts

View file

@ -9,14 +9,12 @@
column render from the same vector and therefore stay aligned without measuring column render from the same vector and therefore stay aligned without measuring
anything. anything.
EVERY FRAME NUMBER A ROW CARRIES IS IN THE OPEN SYMBOL'S FRAME SPACE. An EVERY FRAME NUMBER A ROW CARRIES IS IN THE OPEN SYMBOL'S FRAME SPACE. A node's
instance's keys are in its own symbol's, and drawing them against the ruler keys are in its own frames, and drawing them against the ruler unmapped would
unmapped would
put a key under the wrong frame — silently, and most convincingly when the put a key under the wrong frame — silently, and most convincingly when the
instance starts at 0. So the walk carries a `->open` function and composes one node starts at 0. So the walk carries a `->open` function and composes one more
more mapping into it at each instance. The mapping is the inverse of mapping into it at each level: the inverse of the node's time map,
`node/local-frame`: `local = in + rate·(parent - at)`, so `local = rate·(parent − at)`, so `parent = at + local/rate`.
`parent = at + (local - in)/rate`.
WHAT IT DOES NOT DO YET: a looping instance repeats its symbol, and only the WHAT IT DOES NOT DO YET: a looping instance repeats its symbol, and only the
first pass is drawn. An expanded loop therefore shows keys where they first first pass is drawn. An expanded loop therefore shows keys where they first
@ -37,28 +35,22 @@
;; the rows ;; the rows
(defn- local->parent (defn- local->parent
"The inverse of `node/local-frame`: where a frame of this node's OWN time sits "The inverse of a node's time map: where a frame of its OWN time sits in the
in the symbol it lives in. symbol it lives in. See `node/time-of`.
Two frame spaces meet at every node and mixing them up is the bug this exists Two frame spaces meet at every node and mixing them up is the bug this exists
to prevent. A node's `:span` is checked against the frame its PARENT hands it, to prevent: a placement `:at 48` whose scale is keyed at 0 has that key on
but its channels are read at `local-frame` of that — so a placement `:at 48` frame 48 of the symbol it is in, and drawing it at 0 puts every placement's
whose scale is keyed at 0 has that key on stage frame 48, and drawing it at 0 keys in the same place however staggered they are.
puts every placement's keys in the same place however staggered they are.
Exposure is not inverted, because it is a floor and has no inverse: a key on a Exposure is not inverted, because it is a floor and has no inverse: a key on a
frame the exposure grid never samples is still authored on that frame, and that frame the exposure grid never samples is still authored on that frame, and that
is where the row should show it." is where the row should show it."
[n] [n]
(let [{:keys [mode offset at rate] :or {mode :inherit at 0 rate 1}} (:time n) (let [{:keys [at rate]} (node/time-of n)]
in (or (first (:span n)) 0)] (if (and (zero? at) (= 1 rate))
(if (= mode :inherit)
identity identity
(fn [f] (fn [f] (js/Math.round (+ at (/ f rate)))))))
(let [f (- f (or offset 0))]
(if (and (node/timed-kinds (:kind n)) (not (zero? rate)))
(js/Math.round (+ at (/ (- f in) rate)))
f))))))
(defn- keyed-frames [ch] (some-> (:keys ch) keys sort)) (defn- keyed-frames [ch] (some-> (:keys ch) keys sort))

View file

@ -257,9 +257,9 @@
(deftest an-instance-span-is-in-its-own-frames (deftest an-instance-span-is-in-its-own-frames
(let [n {:id :i :kind :instance :of :x :z "a1" :span [3 13] (let [n {:id :i :kind :instance :of :x :z "a1" :span [3 13]
:time {:mode :map :at 40 :rate 2}}] :time {:mode :map :at 40 :rate 2}}]
(is (= [40 45] (node/placed-span n)) "ten own frames at double rate is five") (is (= [41.5 46.5] (node/placed-span n)) "own frames 3 to 13, at double rate, from 40")
(is (= 3 (node/local-frame n 40)) "the parent's :at is the span's first frame") (is (= 0 (node/local-frame n 40)) "the parent's :at is where its own frame 0 lands")
(is (= 11 (node/local-frame n 44))) (is (= 8 (node/local-frame n 44)))
(is (= [2 9] (node/placed-span {:kind :poly :span [2 9]})) (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") "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))) (is (seq (node/problems (assoc-in n [:time :in] 3)))
@ -311,7 +311,7 @@
(deftest a-placed-symbols-sound-is-heard-where-it-is-placed (deftest a-placed-symbols-sound-is-heard-where-it-is-placed
(let [voice {:id :v :kind :audio :source {:footage "f"} :z "a1" (let [voice {:id :v :kind :audio :source {:footage "f"} :z "a1"
:span [10 40] :time {:mode :map :at 0 :rate 1} :span [10 40] :time {:mode :map :at -10 :rate 1}
:channels {[:audio :gain] (ch/keyed {0 0.0 5 1.0})}} :channels {[:audio :gain] (ch/keyed {0 0.0 5 1.0})}}
c (-> (clip/blank) c (-> (clip/blank)
(assoc-in [:symbols :talk] {:id :talk :frames 30 :nodes {:v voice}}) (assoc-in [:symbols :talk] {:id :talk :frames 30 :nodes {:v voice}})
@ -351,3 +351,95 @@
(is (= [55 35] (clip/center grown nil :box)) "the symbol's middle moved") (is (= [55 35] (clip/center grown nil :box)) "the symbol's middle moved")
(is (= [25 35] (get-in grown [:symbols :main :nodes u :channels [:xform :anchor] :value])) (is (= [25 35] (get-in grown [:symbols :main :nodes u :channels [:xform :anchor] :value]))
"the instance's did not"))))) "the instance's did not")))))
(defn- picture
"What `sid` draws at each of `fs`, without the node paths a move changes:
per frame, the sorted marks with their points rounded to a thousandth."
[c sid fs]
(let [resolve (clip/resolver c nil pal/index-of sid)
round #(/ (js/Math.round (* 1000 %)) 1000)]
(mapv (fn [f]
(sort-by str (map (fn [op]
[(:color op) (mapv round (take (* 2 (:n op)) (array-seq (:pts op))))])
(resolve f))))
fs)))
(def ^:private a-uuid #uuid "00000000-0000-4000-8000-0000000000e1")
(def ^:private b-uuid #uuid "00000000-0000-4000-8000-0000000000e2")
(defn- studio
"`main` holds a keyed shape and a moved, turned, doubled instance of `box`,
placed at frame 10; `box` holds a shape of its own."
[]
(let [tri (fn [id x keyed]
{:id id :kind :poly :z "a1" :paint? true :span [4 60]
:channels {[:geom :pts] (ch/keyed (into {} (map (fn [[f dx]] [f [x 10 (+ x dx) 10 x 40]])) keyed))
[:style :color] (ch/framed :brow)}})]
(-> (clip/blank)
(assoc-in [:symbols :main :nodes :tri] (tri :tri 100 {4 20 30 40}))
(assoc-in [:symbols :box] {:id :box :frames 50 :nodes {:inner (tri :inner 5 {0 10})}})
(clip/place-symbol nil :main :box 10 a-uuid nil)
(update-in [:symbols :main :nodes a-uuid :channels] merge
{[:xform :pos] (ch/framed [30 20])
[:xform :rot] (ch/framed 0.5)
[:xform :scale] (ch/framed [2 2])}))))
(deftest moving-a-node-into-a-symbol-changes-nothing-on-screen
;; From frame 10, where the instance starts: inside a symbol a node exists only
;; while that symbol is on screen, so moving the shape in cuts off 4–9.
(let [c (studio)
fs [10 16 29 30 45 59 60 70]
{moved :clip :as r} (clip/move-node c nil :main [:tri] [a-uuid] 16)]
(is (nil? (:refused r)) (:refused r))
(is (contains? (get-in moved [:symbols :box :nodes]) :tri) "it is in the symbol now")
(is (not (contains? (get-in moved [:symbols :main :nodes]) :tri)) "and not beside it")
(is (= [4 60] (get-in moved [:symbols :box :nodes :tri :span]))
"its span and keys are its own and do not change")
(is (= -10 (get-in moved [:symbols :box :nodes :tri :time :at]))
"its time map takes up the ten frames the instance starts late")
(is (= (picture c :main fs) (picture moved :main fs)) "and the picture is the same, frame for frame")
(testing "moving it back out is also invisible"
(let [{back :clip} (clip/move-node moved nil :main [a-uuid :tri] [] 16)]
(is (= (picture c :main fs) (picture back :main fs)))))))
(deftest moving-an-instance-moves-only-its-at
(let [c (-> (studio)
(assoc-in [:symbols :holder] {:id :holder :frames 100 :nodes {}})
(clip/place-symbol nil :main :holder 3 b-uuid nil))
fs [0 10 16 40 59]
{moved :clip :as r} (clip/move-node c nil :main [a-uuid] [b-uuid] 16)
n (get-in moved [:symbols :holder :nodes a-uuid])]
(is (nil? (:refused r)) (:refused r))
(is (= 7 (get-in n [:time :at])) "placed at 10 in main is at 7 inside something placed at 3")
(is (= [0 50] (:span n)) "its own frames do not move")
(is (= (picture c :main fs) (picture moved :main fs)))))
(deftest grouping-makes-a-symbol-around-them-and-changes-nothing-on-screen
(let [c (studio)
fs [0 4 10 16 30 59 60]
{grouped :clip :as r} (clip/group c nil :main [[:tri] [a-uuid]] :group-1 b-uuid 16)
inst (get-in grouped [:symbols :main :nodes b-uuid])]
(is (nil? (:refused r)) (:refused r))
(is (= #{:tri a-uuid} (set (keys (get-in grouped [:symbols :group-1 :nodes])))))
(is (= [b-uuid] (keys (get-in grouped [:symbols :main :nodes]))) "one instance where they were")
(is (= 4 (get-in inst [:time :at])) "starting where the earliest of them starts")
(is (= 56 (clip/frames grouped :group-1)) "and lasting until the last one ends")
(is (= (picture c :main fs) (picture grouped :main fs)))
(is (empty? (clip/problems grouped)))))
(deftest what-cannot-move-says-why
(let [c (studio)]
(is (:refused (clip/move-node c nil :main [a-uuid] [a-uuid] 16)) "not into itself")
(is (:refused (clip/move-node c nil :main [:tri] [a-uuid] 2)) "not while the target is off screen")
(let [roto (assoc-in c [:symbols :main :nodes :tri :channels [:geom :pts] :generated] {:by :roto})]
(is (re-find #"generated" (:refused (clip/move-node roto nil :main [:tri] [a-uuid] 16)))))))
(deftest moving-into-a-retimed-instance-keeps-the-timing
(let [c (assoc-in (studio) [:symbols :main :nodes a-uuid :time :rate] 2)
fs [10 12 17 20 29 34]
{moved :clip :as r} (clip/move-node c nil :main [:tri] [a-uuid] 16)]
(is (nil? (:refused r)) (:refused r))
(is (= {:at -20 :rate 0.5}
(select-keys (get-in moved [:symbols :box :nodes :tri :time]) [:at :rate]))
"half speed inside something at double speed, so it runs as it did")
(is (= (picture c :main fs) (picture moved :main fs)))))

View file

@ -132,16 +132,16 @@
(is (= (vec (range 8)) (mapv #(node/local-frame {:id :x} %) (range 8)))) (is (= (vec (range 8)) (mapv #(node/local-frame {:id :x} %) (range 8))))
(is (= (vec (range 8)) (mapv #(node/local-frame {:id :x :time {:mode :inherit}} %) (range 8))))) (is (= (vec (range 8)) (mapv #(node/local-frame {:id :x :time {:mode :inherit}} %) (range 8)))))
(deftest a-retimed-instance-is-refused-rather-than-ignored (deftest every-node-has-the-same-time-map
;; :rate is a symbol instance's timing and symbols are out of scope. Silently ;; One rule, whatever the node is: `local = rate · (parent − at)`.
;; dropping it would be a retimed blink playing at the wrong speed, which looks (is (= 5 (node/local-frame {:id :x :time {:mode :map :rate 0.5}} 10)) "a shape, at half speed")
;; like a bad blink and not like a missing feature. (is (= 5 (node/local-frame {:id :x :kind :instance :time {:mode :map :rate 0.5}} 10)) "an instance, the same")
(is (thrown-with-msg? ExceptionInfo #":rate" (is (= 3 (node/local-frame {:id :x :time {:mode :map :at 7}} 10)) "and :at is where its frame 0 lands")
(node/local-frame {:id :x :time {:mode :map :rate 0.5}} 0)))
(is (= 4 (node/local-frame {:id :x :time {:mode :map :expose 2 :rate 1.0}} 5)) (is (= 4 (node/local-frame {:id :x :time {:mode :map :expose 2 :rate 1.0}} 5))
"rate 1.0 is the identity and is allowed, because it appears in the spec's example")) "exposure still floors on its own clock")
(let [m {:at 3 :rate 2}]
;; ---- the shape ---- (is (= {:at 0 :rate 1} (node/then-time m (node/invert-time m)))
"a map followed by its inverse is nothing")))
(deftest transform-channels-default-to-the-identity (deftest transform-channels-default-to-the-identity
(let [chs (node/channels {:id :x :kind :group})] (let [chs (node/channels {:id :x :kind :group})]