From 94c0a21de1652195a4314bd8acc17792a4be2be4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 30 Sep 2026 16:07:38 -0400 Subject: [PATCH] A correction is a layer, and a layer's values are a channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:over` was specified in animation-model.md, refused in two places, and produced by nothing: `check-unimplemented!` threw on read and `channel/problems` reported it. It reads now. This is the part of the model the rotoscoping half depends on — generate motion, correct it by hand, turn the knob, keep the correction — and it was the last thing in the design that had never been tried. The shape that made it small: A LAYER'S VALUES ARE A CHANNEL. {:id :nudge :support [88 98] :op :offset :values {:animated? true :interp :linear :keys {88 [2 0], 96 [0 0]}}} So the three commands the lane model asks for over a selected range — a constant adjustment, a ramp, a return motion — are one mechanism and not three: framed values say the same thing on every frame they cover, keyed values move, and neither needs a new way to say what a value is over time. A layer reads through `value-at` and `cursor` like any channel, which is also what stopped blending from becoming two implementations: `over-at` is shared, and the specification and the playback path differ only in how they READ a layer — recursively through `value-at`, or through a reading head of its own. One level deep; a layer's values may not carry layers, which the stack already orders. That was the risk worth spiking for. A cursor that drifts produces the wrong pose rather than an error, and a stack means several reading heads per channel where there was one. The agreement test that holds the cursor to the specification in forward, backward and random frame order now covers stacked channels too — including a layer whose head is asked for nothing across the long stretches outside its support and then asked again, which is where drift would hide. `:support` is half-open and explicit. Outside it the base evaluates exactly as it did before, which is the whole difference between a bounded correction and inserting boundary keys: the latter alters the neighbouring segments, and the lane model says so. A LAYER HAS NO TIME SPACE OF ITS OWN, and this is the design question the doc left open. Its support and its values' keys are in the frames the base channel's keys are in — the node's. A correction on a lane is therefore in lane frames and reaches across the drawings exposed beneath it; one on a single occurrence is in that occurrence's frames and travels with it when the exposure moves. Ownership had already answered it, so there is no field to disagree with, and both halves are under test at lane level. Two things cost nothing, which is worth recording. A channel is ONE LEAF, so a correction persists inside it with no codec change at all. And `node/problems` already reports every channel's problems, so a malformed layer surfaces at the document level and in the sequence commands' post-check without plumbing. What is still missing is a command that MAKES one, and with it the question of how a view offers a constant, a ramp and a return over a selected range. The evaluator no longer has an opinion about that, which was the point. `offset` adds component-wise and never writes into a dense value, which is a view onto the block itself; a shape mismatch throws rather than being dropped, since a correction that silently does not take is the failure this design exists to prevent. `replace` can supply a value over an absent base and `offset` cannot, as animation-model.md required. 408 tests, 5,655 assertions. Co-Authored-By: Claude Opus 5 --- docs/animation-model.md | 24 ++- docs/lane-model.md | 41 +++- frontend/src/arthur/domain/channel.cljs | 198 +++++++++++++----- frontend/test/arthur/domain/channel_test.cljs | 116 +++++++++- frontend/test/arthur/domain/lane_test.cljs | 57 +++++ 5 files changed, 365 insertions(+), 71 deletions(-) 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)))))