diff --git a/docs/animation-model.md b/docs/animation-model.md index a8ebb3d..506ae01 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -79,11 +79,19 @@ 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. +**Every node has the same two maps into its parent**, whatever kind it is: + +- **space** — the matrix its transform channels compose to, times a `:pinv` if + it has been moved in from elsewhere; +- **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 diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index ac7eefe..d221843 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -293,7 +293,8 @@ (assoc-in [:symbols sid :nodes :sound] {:id :sound :name "sound" :kind :audio :parent nil :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)) (update :subjects merge (:subjects take)) (update :features merge (:features take)) @@ -304,31 +305,29 @@ and, recursively, those inside the instances it places. 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 - to the instance's own span — until it is in the frames of the symbol being - played. Keyed automation moves with it. What comes back is what a mixer that - only knows flat tracks can play as it is." + is carried OUT through the instance's time map — the same map a timeline row + draws with — and cut to the instance's own span, until it is in the frames of + the symbol being played. Keyed automation moves with it. What comes back is + what a mixer that only knows flat tracks can play as it is." [clip sid] (let [sym (symbol clip sid)] (into (vec (filter #(= :audio (:kind %)) (vals (:nodes sym)))) (mapcat (fn [inst] - (let [{:keys [mode at rate] :or {at 0 rate 1}} (:time inst) - inner (symbol clip (:of inst)) - [in out] (or (:span inst) [0 (:frames inner)]) - in (if (= mode :map) in 0) - ->outer (fn [x] (+ at (/ (- x in) rate)))] + (let [outer (node/time-of inst) + ->outer (fn [x] (+ (:at outer) (/ x (:rate outer)))) + [in out] (or (:span inst) [0 (frames clip (:of inst))])] (keep (fn [a] - (let [{a-at :at a-rate :rate :or {a-at 0 a-rate 1}} (:time a) - [s0 s1] (or (:span a) [0 (:frames inner)]) - x0 (max a-at in) - x1 (min (+ a-at (/ (- s1 s0) a-rate)) out)] + (let [[p0 p1] (or (node/placed-span a) [in out]) + x0 (max p0 in) + x1 (min p1 out) + own (node/time-of a) + ->own (fn [x] (* (:rate own) (- x (:at own)))) + world (node/then-time outer own)] (when (< x0 x1) (-> a - (assoc :span [(+ s0 (* a-rate (- x0 a-at))) - (+ s0 (* a-rate (- x1 a-at)))] - :time {:mode :map :at (->outer x0) - :rate (* a-rate rate)}) + (assoc :span [(->own x0) (->own x1)] + :time {:mode :map :at (:at world) :rate (:rate world)}) (update :channels (fn [chs] (into {} (map (fn [[p ch]] @@ -387,6 +386,177 @@ [(aget out 0) (aget out 1)])) (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 "Put a symbol's already resolved mark into its instance's parent space." [op m path] diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index 673233b..2472da3 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -103,27 +103,42 @@ (/ 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 time-of + "A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame + `p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A + 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 "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." + A `:span` is in the node's OWN frames — which of its frames exist — for every + node alike, and its time map says where they land in the parent. For a node + with no time map the two are the same frames, so a shape's span reads as it + always did. Moving a node along its parent is then one write to `:at`, and the + span, which says what the node 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]))) + (let [{:keys [at rate]} (time-of n)] + [(+ at (/ in rate)) (+ at (/ out rate))]))) (defn local-frame "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 to performance nodes and not to the plate, which is the entire point of it." [n f] - (let [{:keys [mode offset rate at source-fps sample-fps] - ex :expose :or {mode :inherit}} (:time n)] + (let [{:keys [mode source-fps sample-fps] ex :expose :or {mode :inherit}} (:time n)] (if (= mode :inherit) f (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)))) (throw (ex-info "picture sampling needs a positive source fps" {:node (:id n) :time (:time n)}))) - (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) - offset (+ offset)))))) + (let [{:keys [at rate offset] :or {at 0 rate 1}} (:time n)] + (cond-> (* rate (- f at)) + sample-fps (sample-frame source-fps sample-fps) + ex (expose ex) + offset (+ offset))))))) ;; --------------------------------------------------------------------------- ;; the transform @@ -292,9 +300,9 @@ (and (= k :instance) (nil? (:of n))) (conj "an instance needs :of") (and (= k :audio) (nil? (get-in n [: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])))) - (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") (and (:span n) (not= 2 (count (:span n)))) (conj ":span must be [in out]") diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index 123f232..8d9dba1 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -126,11 +126,10 @@ ;; 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))) - ;; 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`. + ;; A span is in the node's OWN frames and `at` is where its frame 0 sits + ;; in this symbol. See `node/placed-span`. "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]) [: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 e90b1fe..1563fa6 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -9,14 +9,12 @@ column render from the same vector and therefore stay aligned without measuring anything. - EVERY FRAME NUMBER A ROW CARRIES IS IN THE OPEN SYMBOL'S FRAME SPACE. An - instance's keys are in its own symbol's, and drawing them against the ruler - unmapped would + EVERY FRAME NUMBER A ROW CARRIES IS IN THE OPEN SYMBOL'S FRAME SPACE. A node's + keys are in its own frames, and drawing them against the ruler unmapped would 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 - more mapping into it at each instance. The mapping is the inverse of - `node/local-frame`: `local = in + rate·(parent - at)`, so - `parent = at + (local - in)/rate`. + node starts at 0. So the walk carries a `->open` function and composes one more + mapping into it at each level: the inverse of the node's time map, + `local = rate·(parent − at)`, so `parent = at + local/rate`. 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 @@ -37,28 +35,22 @@ ;; the rows (defn- local->parent - "The inverse of `node/local-frame`: where a frame of this node's OWN time sits - in the symbol it lives in. + "The inverse of a node's time map: where a frame of its OWN time sits in the + symbol it lives in. See `node/time-of`. 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, - but its channels are read at `local-frame` of that — so a placement `:at 48` - whose scale is keyed at 0 has that key on stage frame 48, and drawing it at 0 - puts every placement's keys in the same place however staggered they are. + to prevent: a placement `:at 48` whose scale is keyed at 0 has that key on + frame 48 of the symbol it is in, and drawing it at 0 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 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 rate] :or {mode :inherit at 0 rate 1}} (:time n) - in (or (first (:span n)) 0)] - (if (= mode :inherit) + (let [{:keys [at rate]} (node/time-of n)] + (if (and (zero? at) (= 1 rate)) identity - (fn [f] - (let [f (- f (or offset 0))] - (if (and (node/timed-kinds (:kind n)) (not (zero? rate))) - (js/Math.round (+ at (/ (- f in) rate))) - f)))))) + (fn [f] (js/Math.round (+ at (/ f rate))))))) (defn- keyed-frames [ch] (some-> (:keys ch) keys sort)) diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index c9179ea..5e652de 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -257,9 +257,9 @@ (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 (= [41.5 46.5] (node/placed-span n)) "own frames 3 to 13, at double rate, from 40") + (is (= 0 (node/local-frame n 40)) "the parent's :at is where its own frame 0 lands") + (is (= 8 (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))) @@ -311,7 +311,7 @@ (deftest a-placed-symbols-sound-is-heard-where-it-is-placed (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})}} c (-> (clip/blank) (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 (= [25 35] (get-in grown [:symbols :main :nodes u :channels [:xform :anchor] :value])) "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))))) diff --git a/frontend/test/arthur/domain/node_test.cljs b/frontend/test/arthur/domain/node_test.cljs index 761d31a..15aa746 100644 --- a/frontend/test/arthur/domain/node_test.cljs +++ b/frontend/test/arthur/domain/node_test.cljs @@ -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 :time {:mode :inherit}} %) (range 8))))) -(deftest a-retimed-instance-is-refused-rather-than-ignored - ;; :rate is a symbol instance's timing and symbols are out of scope. Silently - ;; dropping it would be a retimed blink playing at the wrong speed, which looks - ;; like a bad blink and not like a missing feature. - (is (thrown-with-msg? ExceptionInfo #":rate" - (node/local-frame {:id :x :time {:mode :map :rate 0.5}} 0))) +(deftest every-node-has-the-same-time-map + ;; One rule, whatever the node is: `local = rate · (parent − at)`. + (is (= 5 (node/local-frame {:id :x :time {:mode :map :rate 0.5}} 10)) "a shape, at half speed") + (is (= 5 (node/local-frame {:id :x :kind :instance :time {:mode :map :rate 0.5}} 10)) "an instance, the same") + (is (= 3 (node/local-frame {:id :x :time {:mode :map :at 7}} 10)) "and :at is where its frame 0 lands") (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")) - -;; ---- the shape ---- + "exposure still floors on its own clock") + (let [m {:at 3 :rate 2}] + (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 (let [chs (node/channels {:id :x :kind :group})]