diff --git a/docs/animation-model.md b/docs/animation-model.md index 25a0ab2..77b3478 100644 --- a/docs/animation-model.md +++ b/docs/animation-model.md @@ -235,10 +235,30 @@ combines: ```clojure {:animated? true :interp :hold :dense {...} :generated {...} - :over [{:blend :offset :keys {88 [2 0], 96 [0 0]}} - {:blend :replace :keys {104 [[3 7] [4 7] …]}}]} + :over [{:id :nudge :support [88 98] :op :offset + :values {:animated? true :interp :linear :keys {88 [2 0], 96 [0 0]}}} + {:id :redraw :support [104 105] :op :replace + :values {:animated? false :value [[3 7] [4 7] …]}}]} ``` +A LAYER'S VALUES ARE A CHANNEL, which is what keeps a constant adjustment, a +ramp and a return motion from being three mechanisms: a framed one says the same +thing on every frame it covers, a keyed one moves. They read through `value-at` +and `cursor` like any channel, one reading head each, so the specification and +the playback path share their blending and differ only in how they read — and a +layer's values may not carry layers of their own, which the stack already +orders. + +`:support` is half-open and explicit, `[in out)`. Outside it a layer is inactive +and the base evaluates exactly as it did before, which is the difference between +a bounded correction and inserting boundary keys — the latter alters the +neighbouring segments. And a layer has NO TIME SPACE of its own: its support and +its values' keys are in the frames the base channel's keys are in, the node's +own. A correction on a lane is therefore in lane frames and reaches across the +drawings exposed under it; one on a single occurrence is in that occurrence's +frames and travels with it when the exposure moves. Ownership had already +answered the question, so there is no field to disagree with. + - **`:offset`** adds a delta to the base. "Nudge the mouth two pixels right for ten frames" survives a re-freeze at different parameters, because it was never a position — it was a correction. diff --git a/docs/lane-model.md b/docs/lane-model.md index 718502c..2ae0054 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -1,9 +1,10 @@ # The Lane Model Revised 2026-09-30. Target design. Occurrence ownership, source playback, the -content and exposure commands, placement anywhere in a lane, and a one-row cel -strip are implemented; correction layers, overwrite and the retiming commands, -and the remaining views are not. See the status note under +content and exposure commands, placement anywhere in a lane, a one-row cel strip +and the correction-layer evaluator are implemented; the commands that produce a +correction, overwrite, the retiming commands and the remaining views are not. +See the status note under [Proof obligations](#proof-obligations-and-implementation-order). This revises the Claude artifact [The Lane Model](https://claude.ai/code/artifact/cd42981d-ed08-493f-94df-b7dd6657f0e6). @@ -471,14 +472,32 @@ Content copies are shallow by default and keep their references to other symbols; `:deep? true` is the explicit copy that shares nothing, so the promise of independence is only made where it is kept. -Correction layers, overwrite, and the range and retiming commands (blank, trim, -move, slip source, retime) and the exposure-sheet view are not implemented; a -refusal is the current behavior where the model demands an explicit choice -nobody has made yet. The suite stands at 401 tests and 5,612 assertions, with -`frontend/test/browser/sequence.mjs` driving the editor through create, hold, -overflow, undo, reuse, make unique, duplicate, split and insert. Rewrite tests -that encode superseded behavior rather than preserving behavior to keep them -green. +Correction layers EVALUATE. `channel/problems` used to refuse an `:over` stack +and `value-at`/`cursor` used to throw on one; both now read it, and the +agreement test that holds the optimized cursor to the specification covers +stacked channels in forward, backward and random frame order. A layer's values +are themselves a channel, so a constant adjustment, a ramp and a return motion +are one mechanism; `:support` is half-open and a layer is inactive outside it; +and a layer has no time space of its own, because the node its channel is on +already has one. Nothing had to change in the codec — a channel is one leaf, so +a correction persists inside it — and nothing had to change in validation +plumbing, since `node/problems` already reports every channel's problems. + +Both halves of ownership are under test at lane level: a three-frame correction +on the girl's lane reaches across the drawing boundary beneath it and leaves +every frame outside its support identical, and a correction owned by one +exposure travels with that exposure when a hold before it grows. + +What is NOT implemented is a command that produces a layer — the doc's Constant +adjustment, Ramp and Return motion — and with it the question of how a view +offers those three over a selected range. Overwrite, the range and retiming +commands (blank, trim, move, slip source, retime) and the exposure-sheet view +are also not implemented; a refusal is the current behavior where the model +demands an explicit choice nobody has made yet. The suite stands at 408 tests +and 5,655 assertions, with `frontend/test/browser/sequence.mjs` driving the +editor through create, hold, overflow, undo, reuse, make unique, duplicate, +split and insert. Rewrite tests that encode superseded behavior rather than +preserving behavior to keep them green. Build small adversarial documents and test their domain operations before expanding the interface: diff --git a/frontend/src/arthur/domain/channel.cljs b/frontend/src/arthur/domain/channel.cljs index d61262a..9f9bd11 100644 --- a/frontend/src/arthur/domain/channel.cljs +++ b/frontend/src/arthur/domain/channel.cljs @@ -94,17 +94,75 @@ (when-let [ks (:keys ch)] (vec (sort (keys ks))))) -(defn- check-unimplemented! - "An override layer must fail LOUDLY rather than be ignored. +;; --------------------------------------------------------------------------- +;; correction layers +;; +;; `:over` is an ORDERED STACK on top of whatever the channel already says. +;; Generated motion stays the base; a hand correction is a layer above it, so +;; regenerating replaces the base and the corrections survive. That is the whole +;; reason the stack exists rather than the hand edit being written into the keys. +;; +;; A LAYER'S VALUES ARE A CHANNEL. A constant adjustment is a framed one, a ramp +;; or a return motion is a keyed one, and neither needs a second way of saying +;; what a value is over time: layers read through `value-at` and `cursor` like +;; anything else, which is also what stops the fast path and the specification +;; from being two implementations of blending. +;; +;; A LAYER HAS NO TIME SPACE OF ITS OWN. Its `:support` and its values' keys are +;; in the frames the base channel's keys are in — the node's own. A correction on +;; a lane is therefore in lane frames and crosses the drawing boundaries under +;; it; a correction on one occurrence is in that occurrence's frames and travels +;; with it when it moves. Ownership already answered the question, so there is no +;; field to get wrong. - Silently dropping an :over layer would present as a hand - correction that did not take — a correction the user made once, watched fail, - and has no reason to trust again. Nothing can produce one yet, so this can - only fire on a data shape that has run ahead of the code." - [ch] - (when (seq (:over ch)) - (throw (ex-info "channel has :over layers and the override layer is not built (port-plan step 2 scope)" - {:over (:over ch) :channel (dissoc ch :dense)})))) +(defn layer + "One correction: `values` applied to the base wherever `support` covers the + frame. `op` is `:offset` or `:replace`." + [id support op values] + {:id id :support support :op op :values values}) + +(defn- covers? + "Half-open, as a span is: a correction over frames 10 to 12 is `[10 13)`." + [[in out] f] + (and (<= in f) (< f out))) + +(defn- width + "Components in a value, or nil for a number. A dense value is a typed-array + view, an authored one a vector, and a correction has to add to either." + [v] + (cond (number? v) nil (vector? v) (count v) :else (.-length v))) + +(defn- offset-onto + "`base` plus `v`, component-wise. A vector, never a write into `base`, which + for a dense channel is a view onto the block itself." + [base v ch] + (let [wb (width base) wv (width v)] + (cond + (and (nil? wb) (nil? wv)) (+ base v) + (and wb wv (= wb wv)) + (mapv (fn [i] (+ (component base i) (component v i))) (range wb)) + :else + (throw (ex-info "a correction cannot offset a value of a different shape" + {:base wb :correction wv :channel (dissoc ch :dense)}))))) + +(defn- over-at + "Fold `ch`'s layers onto `base` at frame f. `read` samples one layer's values + and is the only thing that differs between the specification and the cursor." + [ch f base read] + (reduce-kv + (fn [v i {:keys [support op values]}] + (if-not (covers? support f) + v + (let [x (read i values f)] + (cond + (nothing? x) v + (= :replace op) x + ;; `replace` can supply a value over an absent base; `offset` has + ;; nothing to add to and says so rather than inventing a pose. + (nothing? v) absent + :else (offset-onto v x ch))))) + base + (vec (:over ch)))) ;; --------------------------------------------------------------------------- ;; dense blocks @@ -213,14 +271,17 @@ how `gesture/values` took the stage down on an iris. A caller with no store says `nil` and means it." [ch f store] - (check-unimplemented! ch) - (cond - (not (:animated? ch)) (:value ch) - (:dense ch) (dense-at (:dense ch) f store nil) - (:keys ch) (let [ks (:keys ch)] - (if (empty? ks) absent (keyed-at ch f))) - :else - (throw (ex-info "animated channel has neither :keys nor :dense" {:channel ch})))) + (let [base (cond + (not (:animated? ch)) (:value ch) + (:dense ch) (dense-at (:dense ch) f store nil) + (:keys ch) (let [ks (:keys ch)] + (if (empty? ks) absent (keyed-at ch f))) + :else + (throw (ex-info "animated channel has neither :keys nor :dense" + {:channel ch})))] + (if (seq (:over ch)) + (over-at ch f base (fn [_ values f] (value-at values f store))) + base))) ;; --------------------------------------------------------------------------- ;; the playback path @@ -242,7 +303,7 @@ (recur (inc mid) hi mid) (recur lo (dec mid) best)))))) -(deftype Cursor [ch ks store buf ^:mutable i] +(deftype Cursor [ch ks store buf overs ^:mutable i] Object (toString [_] (str "#Cursor{" (pr-str (if ks :keyed (if (:dense ch) :dense :framed))) " i=" i "}"))) @@ -255,47 +316,64 @@ Only a wide fixed-point block gets a buffer: a stride-1 block decodes to a number and a block with no `:scale` is handed back as a view. + A correction layer gets a reading head of its own, because its values are a + channel and this is how a channel is read fast. One level deep: a layer's + values may not themselves carry layers, which `problems` refuses. + `store` is an argument for the reason it is one on `value-at`." [ch store] - (check-unimplemented! ch) (let [d (:dense ch)] (->Cursor ch (when (and (:animated? ch) (not d) (seq (:keys ch))) (frames ch)) store (when (and d (:scale d) (> (:stride d) 1)) (js/Float64Array. (:stride d))) + (mapv #(cursor (:values %) store) (:over ch)) 0))) +(defn- base-sample! + "What the cursor's channel says at f BEFORE its corrections. Advancing the + reading head is this function's whole job, and it is separate from blending so + that a layer cannot accidentally be read through the base's index." + [^Cursor cur ch ks f] + (cond + (not (:animated? ch)) (:value ch) + (:dense ch) (dense-at (:dense ch) f (.-store cur) (.-buf cur)) + (nil? ks) absent ; animated with an empty key map + :else + (let [n (count ks) + i (.-i cur) + last (dec n) + i' (cond + ;; still inside the key the cursor sits on + (and (<= (nth ks i) f) + (or (= i last) (> (nth ks (inc i)) f))) + i + ;; the next one — one frame of playback crossed one key + (and (< i last) + (<= (nth ks (inc i)) f) + (or (= (inc i) last) (> (nth ks (+ i 2)) f))) + (inc i) + + :else (bsearch ks f))] + (set! (.-i cur) i') + (interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i'))))))) + (defn sample! "Value of the cursor's channel at f. O(1) when f is at or one key past where the cursor already sits — the playback case — and O(log n) otherwise, which is a seek. Advancing and seeking are deliberately different costs: a scrub can - afford a binary search and a frame cannot." - [^Cursor cur f] - (let [ch (.-ch cur) - ks (.-ks cur)] - (cond - (not (:animated? ch)) (:value ch) - (:dense ch) (dense-at (:dense ch) f (.-store cur) (.-buf cur)) - (nil? ks) absent ; animated with an empty key map - :else - (let [n (count ks) - i (.-i cur) - last (dec n) - i' (cond - ;; still inside the key the cursor sits on - (and (<= (nth ks i) f) - (or (= i last) (> (nth ks (inc i)) f))) - i - ;; the next one — one frame of playback crossed one key - (and (< i last) - (<= (nth ks (inc i)) f) - (or (= (inc i) last) (> (nth ks (+ i 2)) f))) - (inc i) + afford a binary search and a frame cannot. - :else (bsearch ks f))] - (set! (.-i cur) i') - (interpolate ch f (nth ks i') (when (< i' last) (nth ks (inc i')))))))) + A correction layer is sampled through its OWN cursor, so a stacked channel is + still one reading head per key map and `value-at` stays the specification for + the blending as well as for the base." + [^Cursor cur f] + (let [ch (.-ch cur) + base (base-sample! cur ch (.-ks cur) f)] + (if (seq (:over ch)) + (over-at ch f base (fn [i _ f] (sample! (nth (.-overs cur) i) f))) + base))) ;; --------------------------------------------------------------------------- @@ -355,8 +433,34 @@ (or (:dense ch) (not linear-values?))) (conj ":linear interpolation needs numeric keys of one shape") - (and (map? ch) (seq (:over ch))) - (conj ":over layers are not implemented (port-plan step 2 scope)") + (and (map? ch) (contains? ch :over) (not (vector? (:over ch)))) + (conj ":over is an ORDERED stack, so it is a vector") + + (and (map? ch) (vector? (:over ch))) + (into (for [{:keys [id support op values]} (:over ch) + p (cond-> [] + (nil? id) + (conj "needs an :id — a correction has an identity a regeneration can keep") + + (not (and (vector? support) (= 2 (count support)) + (every? #(and (number? %) (js/Number.isFinite %)) support) + (< (first support) (second support)))) + (conj (str ":support " (pr-str support) + " must be a finite, increasing [in out)")) + + (not (#{:offset :replace} op)) + (conj (str ":op " (pr-str op) " is not :offset or :replace")) + + ;; One level. A layer over a layer is an ordering mechanism + ;; the stack already is, and it would make the read + ;; unbounded in depth for nothing. + (seq (:over values)) + (conj "a layer's values cannot carry layers of their own") + + (seq (problems (dissoc values :over))) + (conj (str "values are not a channel: " + (first (problems (dissoc values :over))))))] + (str "correction " (pr-str id) " " p))) ;; A scale of zero divides every value in the block by zero, and a negative ;; one mirrors the geometry. Both are authored-data bugs that present as a diff --git a/frontend/test/arthur/domain/channel_test.cljs b/frontend/test/arthur/domain/channel_test.cljs index 7c85efe..6c95624 100644 --- a/frontend/test/arthur/domain/channel_test.cljs +++ b/frontend/test/arthur/domain/channel_test.cljs @@ -159,7 +159,26 @@ (doseq [[label c] [["sparse" (ch/keyed {0 :a, 4 :b, 12 :c, 13 :d, 40 :e} :hold)] ["one key" (ch/keyed {7 :only} :hold)] ["dense-ish" (ch/keyed (into {} (map (juxt identity #(* 10 %))) (range 40)) :hold)] - ["framed" (ch/framed :static)]]] + ["framed" (ch/framed :static)] + ;; A stacked channel has a reading head per key map, and a + ;; layer's head is asked for nothing at all across the long + ;; stretches outside its support — then asked again. That is + ;; the drift this test exists to catch, now squared. + ["corrected" + (assoc (ch/keyed {0 0, 20 200, 44 440} :linear) + :over [(ch/layer :a [10 20] :offset + (ch/keyed {10 1, 19 90} :linear))])] + ["corrected twice, and over a constant" + (assoc (ch/keyed {0 0, 30 300} :linear) + :over [(ch/layer :a [5 15] :offset (ch/framed 7)) + (ch/layer :b [12 25] :replace + (ch/keyed {12 -1, 24 -12} :linear)) + (ch/layer :c [20 44] :offset + (ch/keyed {20 0, 43 23} :linear))])] + ["a corrected framed base" + (assoc (ch/framed 3) + :over [(ch/layer :a [8 36] :offset + (ch/keyed {8 0, 35 27} :linear))])]]] (let [spec #(ch/value-at c % nil) forward (range 0 45) back (reverse forward) @@ -213,17 +232,92 @@ (is (= [1 2 3 4 5] (mapv #(ch/sample! cur %) (range 5)))) (is (= [5 1] (mapv #(ch/sample! cur %) [4 0])) "and seeks"))) -;; ---- what is deliberately not built has to fail loudly ---- +;; ---- correction layers ---- -(deftest an-override-layer-is-refused-rather-than-ignored - ;; :over is specified in docs/animation-model.md and out of scope for this - ;; step. Dropping one silently would present as a hand correction that did not - ;; take — a correction the user made once, watched fail, and has no reason to - ;; trust again. - (let [c (assoc (ch/keyed {0 [0 0]} :hold) :over [{:blend :offset :keys {0 [2 0]}}])] - (is (thrown-with-msg? ExceptionInfo #":over" (ch/value-at c 0 nil))) - (is (thrown-with-msg? ExceptionInfo #":over" (ch/cursor c nil))) - (is (seq (ch/problems c))))) +(defn- corrected + "A base channel with layers over it." + [base & layers] + (assoc base :over (vec layers))) + +(deftest a-correction-applies-only-over-its-support + ;; THE property. A range says where an edit applies, so outside it the + ;; underlying animation must evaluate exactly as it did before — which is what + ;; makes a bounded correction different from inserting boundary keys, and the + ;; reason the stack exists at all. + (let [base (ch/keyed {0 0, 10 100} :linear) + c (corrected base (ch/layer :nudge [3 6] :offset (ch/framed 5))) + plain (mapv #(ch/value-at base % nil) (range 11)) + with (mapv #(ch/value-at c % nil) (range 11))] + (is (= (concat (take 3 plain) [35 45 55] (drop 6 plain)) with)) + (is (= (assoc plain 3 35 4 45 5 55) with) "and nothing else moved at all"))) + +(deftest a-return-motion-is-a-keyed-layer-and-a-constant-is-a-framed-one + ;; The three commands the lane model asks for over one selected range, and + ;; none of them needs a new way to say what a value is over time. + (let [base (ch/keyed {0 0} :hold) + at (fn [c] (mapv #(ch/value-at c % nil) (range 6)))] + (is (= [0 0 10 10 10 0] + (at (corrected base (ch/layer :flat [2 5] :offset (ch/framed 10))))) + "a constant adjustment") + (is (= [0 0 0 5 10 0] + (at (corrected base (ch/layer :ramp [2 5] :offset + (ch/keyed {2 0, 4 10} :linear))))) + "a ramp") + (is (= [0 0 0 10 0 0] + (at (corrected base (ch/layer :return [2 5] :offset + (ch/keyed {2 0, 3 10, 4 0} :linear))))) + "and a return motion, which is three samples and no new mechanism"))) + +(deftest the-stack-is-ordered-and-replace-wins-where-it-covers + (let [base (ch/keyed {0 1} :hold) + two (fn [a b] (mapv #(ch/value-at (corrected base a b) % nil) (range 4))) + add2 (ch/layer :a [0 4] :offset (ch/framed 2)) + put9 (ch/layer :b [1 3] :replace (ch/framed 9))] + (is (= [3 9 9 3] (two add2 put9)) "the later layer sees the earlier one's result") + (is (= [3 11 11 3] (two put9 add2)) "and order therefore matters"))) + +(deftest replace-can-supply-a-value-where-offset-has-nothing-to-add-to + ;; An absent value is not zero. `replace` states a pose; `offset` cannot + ;; offset a pose that was never measured, and must not invent one. + (let [gone (ch/keyed {} :hold)] + (is (ch/nothing? (ch/value-at gone 0 nil))) + (is (ch/nothing? (ch/value-at (corrected gone (ch/layer :o [0 2] :offset (ch/framed 5))) 0 nil))) + (is (= 5 (ch/value-at (corrected gone (ch/layer :r [0 2] :replace (ch/framed 5))) 0 nil))) + ;; And outside the support it is still absent, not the layer's value. + (is (ch/nothing? (ch/value-at (corrected gone (ch/layer :r [0 2] :replace (ch/framed 5))) 7 nil))))) + +(deftest a-correction-offsets-geometry-component-wise-over-a-dense-base + ;; The base a correction matters most for is generated, and a dense value is a + ;; VIEW onto the block: offsetting must not write into it. + (let [data (js/Int16Array. #js [10 20, 30 40]) + store {"blk" {:data data :state nil}} + base {:animated? true :dense {:store "blk" :offset 0 :stride 2 :frames 2}} + c (corrected base (ch/layer :nudge [1 2] :offset (ch/framed [5 -5])))] + (is (= [[10 20] [35 35]] + (mapv (fn [f] (let [v (ch/value-at c f store)] + [(ch/component v 0) (ch/component v 1)])) + (range 2)))) + (is (= [10 20 30 40] (vec data)) "the block itself is untouched") + (is (thrown-with-msg? + ExceptionInfo #"different shape" + (ch/value-at (corrected base (ch/layer :bad [0 2] :offset (ch/framed 1))) 0 store)) + "a correction of the wrong shape is loud, not silently dropped"))) + +(deftest a-malformed-correction-is-reported-rather-than-read + (let [ok (ch/layer :a [0 2] :offset (ch/framed 1)) + base (ch/keyed {0 1} :hold)] + (is (empty? (ch/problems (corrected base ok)))) + (is (seq (ch/problems (assoc base :over (list ok)))) "an ordered stack is a vector") + (doseq [[label bad] [["no id" (dissoc ok :id)] + ["backwards support" (assoc ok :support [5 2])] + ["open-ended support" (assoc ok :support [0 ##Inf])] + ["a support that is not a pair" (assoc ok :support 3)] + ["an unknown op" (assoc ok :op :multiply)] + ["no values" (dissoc ok :values)] + ["values that are not a channel" (assoc ok :values {:keys {0 1}})] + ["layers under a layer" + (assoc ok :values (corrected base ok))]]] + (is (seq (ch/problems (corrected base bad))) label)))) (deftest an-empty-over-is-fine-and-is-what-scenes-carry (is (empty? (ch/problems (ch/keyed {0 1} :hold)))) diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs index d089add..8138088 100644 --- a/frontend/test/arthur/domain/lane_test.cljs +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -427,3 +427,60 @@ (get-in after [:symbols :main :nodes :girl :channels])) "the performance is still timed the way it was authored") (is (empty? (clip/problems after))))) + +(deftest a-three-frame-correction-crosses-a-drawing-boundary + ;; The lane model's worked example. The correction belongs to the GIRL, so it + ;; applies across whichever drawings are showing under it, and outside its + ;; three frames the animation evaluates exactly as it did before. + (let [doc (document) + fs (range 12) + before (drawn doc fs) + beat (ch/layer :beat [3 6] :offset (ch/framed [30 0])) + c (update-in doc [:symbols :main :nodes :girl :channels [:xform :pos] :over] + (fnil conj []) beat) + after (drawn c fs) + outside [0 1 2 6 7 8 9 10 11]] + (is (empty? (clip/problems c))) + (is (= (mapv before outside) (mapv after outside)) + "outside the support, frame for frame identical") + (let [at (sample c [3 4 5])] + ;; Frame 3 shows drawing A and frames 4 and 5 show drawing B: one + ;; correction, reaching across the cut between them. + (is (= 70 (get-in at [3 [:a :mark]]))) + (is (= 90 (get-in at [4 [:b :mark]]))) + (is (= 102 (get-in at [5 [:b :mark]])) "and B's own correction still applies under it") + (is (= [-10 0 10] (mapv (fn [f] (js/Math.round (get-in at [f :plate]))) [3 4 5])) + "while the background, which is not in the lane, does not move")) + ;; One document change: one step, and it persists in the channel's own leaf. + (let [b (leaf/leaves :p doc) + a (leaf/leaves :p c) + h (-> nil history/hold (history/record b a 0) history/settle)] + (is (= 1 (count (:done h)))) + (is (= b (:leaves (history/undo h a)))) + (is (= c (leaf/clip :p a)) "a correction needs no codec of its own")))) + +(deftest a-correction-on-one-exposure-travels-with-it + ;; The other half of ownership: a layer on an occurrence is in that + ;; occurrence's own frames, so moving the exposure moves the correction and + ;; nothing has to say so. + (let [beat (ch/layer :beat [0 2] :offset (ch/framed [7 0])) + doc (update-in (document) [:symbols :main :nodes :b :channels [:xform :pos] :over] + (fnil conj []) beat) + moved (:clip (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}))] + ;; Stated as the difference from the same document without the correction, + ;; so the claim is about WHERE the layer applies and not about arithmetic. + (let [nudge (fn [with without f] + (- (get-in (sample with [f]) [f [:b :mark]]) + (get-in (sample without [f]) [f [:b :mark]])))] + (is (= [7 7 0 0] (mapv #(nudge doc (document) %) [4 5 6 7])) + "B's first two frames, which are lane frames 4 and 5") + (is (= [7 7 0 0] + (mapv #(nudge moved (:clip (sequence/extend-hold (document) :main :a 2 + {:extent :grow-symbol})) + %) + [6 7 8 9])) + "and after A's hold grows, B's first two frames, which are now 6 and 7")) + (is (= (get-in doc [:symbols :main :nodes :b :channels]) + (get-in moved [:symbols :main :nodes :b :channels])) + "the layer itself was not touched by the retiming") + (is (empty? (clip/problems moved)))))