From 76106d36eed43bdecf84941aa5ec30955d0ab5b2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 30 Sep 2026 16:32:59 -0400 Subject: [PATCH] The shot is as long as somebody said it was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim, move and blank, and the decision they all three walked into: is the shot's length authored, or derived from what is in it? AUTHORED. `:frames` is the symbol's window — how long the shot IS — and the occupied extent of its lanes is a different fact, read off the occurrences. A command grows the window when the caller says `:grow-symbol` and NEVER shrinks it, so blanking the end of a shot leaves a shot with empty frames at the end. That is a true statement about what somebody authored, and the alternative is deleting the last drawing and quietly shortening the film. `finish` had the right behaviour by accident — `(apply max (:frames sym) ...)` — and now says which number is which: `needed` is where the occurrences reach, `:frames` is what was authored, and the only thing that makes the second follow the first is a caller asking. The three commands turned out to be one piece of geometry, which is `split`'s. A `:span` is in the occurrence's OWN frames and `:time` says where those land in the lane, so moving an edge of an exposure is ONE WRITE to `:span` and `:time` and `:playback` are never touched. `local` and `edged` are the whole of it, and split now goes through them too. trim narrows one edge and moves nothing else. Lengthening is `extend-hold`, which carries a ripple policy and a shot-length policy because it needs them; letting trim grow as well would give one gesture two sets of rules and a way to overlap its neighbour. move one write to `:time :at`, and a destination that would overlap is REFUSED rather than rippled. Moving a drawing and re-timing the ones around it are different intentions, and a move that pushed the rest would be the second wearing the first one's name. Clear the room first. blank leaves a gap and does not close it. Wholly inside the range goes, overlapping an end is trimmed to it, spanning the range is split — the one case that needs an ID, and it asks for one instead of inventing it. Because the source clock is untouched, trimming the front of a playing insert starts it LATER INTO its animation rather than restarting it, which is the difference between trimming and slipping and the reason they stay two commands. The test samples the frames it kept and asserts they show what they showed. Blanking leaves the drawings in the library. A lane does not own its content, and a drawing whose last exposure is gone is still a drawing somebody made. Overwrite is now `blank` then `place` and needs no policy argument of its own, which is why it still is not one. 424 tests, 5,749 assertions. The browser flow trims an exposure at the playhead, moves it into the gap that made, blanks it, and checks the shot is still as long as it was authored. Co-Authored-By: Claude Opus 5 --- docs/lane-model.md | 42 +++++- frontend/src/arthur/domain/sequence.cljs | 157 ++++++++++++++++++--- frontend/src/arthur/events/ui.cljs | 43 ++++++ frontend/src/arthur/ui/timeline.cljs | 12 ++ frontend/test/arthur/domain/lane_test.cljs | 124 ++++++++++++++++ frontend/test/browser/sequence.mjs | 25 +++- 6 files changed, 380 insertions(+), 23 deletions(-) diff --git a/docs/lane-model.md b/docs/lane-model.md index 00da7b8..4756a09 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -444,7 +444,7 @@ and carries its own playback clock, and `node/problems` rejects the old `[:source]` channel. What a lane IS lives in `arthur.domain.symbol` beside the other rules about a node map; `arthur.domain.sequence` holds the commands over one — add lane, place a drawing (new, reused or duplicated), make unique, split, -and extend hold. Each is one history step, and each refuses rather than +trim, move, blank and extend hold. Each is one history step, and each refuses rather than half-applying. The timeline draws a lane's occurrences as cel blocks on the lane's own row, and offers Make unique only where the selected exposure actually shares its drawing. @@ -472,6 +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. +THE SHOT LENGTH IS AUTHORED, which is the decision the range commands forced. +`:frames` is the symbol's window — how long the shot IS — and the occupied +extent of its lanes is a different fact derived from the occurrences. A command +grows the window only when the caller says `:grow-symbol`, and never shrinks it: +blanking the end of a shot leaves a shot with empty frames at the end, because +that is a true statement about what somebody authored, and deriving the window +from the extent would make deleting the last drawing quietly shorten the film. +`finish` keeps the two numbers apart by name now rather than by a `max` that +read like an accident. + +Trim NARROWS one edge and moves nothing else; lengthening is `extend-hold`, +which carries the ripple and shot-length policies because it needs them. +Move is one write to `:time :at` and REFUSES a destination that would overlap, +because moving a drawing and re-timing the ones around it are different +intentions — clear the room with `blank` or `trim` first, which is the +composition. Blank leaves a gap and does not close it; an exposure wholly inside +the range goes, one overlapping an end is trimmed to it, and the one spanning +the range is split. Their drawings stay in the library, since a lane does not +own its content. + +All three are the same geometry as `split`: a `:span` is in the occurrence's own +frames, so moving an edge is one write and `:time` and `:playback` are never +touched. That is why trimming the front of a playing insert starts it later into +its animation instead of restarting it — the difference between trimming and +slipping, and the reason they stay separate commands. + 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 @@ -501,17 +527,23 @@ re-measurement for good; and an incompatible offset threw in the read path, which would have taken the stage down on exactly the case the model says to report. +Still unbuilt: overwrite as a placement policy (which is now `blank` then +`place`, composed inside one transaction), slip source, retime, and deleting +reused content. A lane cannot hold AUDIO occurrences — `sequence-problems` +requires visual ones, though this document says a lane may hold either and +should reject only a mixture. + 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, and how it offers a conflict for resolution. 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 414 tests -and 5,696 assertions, with `frontend/test/browser/sequence.mjs` driving the +demands an explicit choice nobody has made yet. The suite stands at 424 tests +and 5,749 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. +split, insert, trim, move and blank. 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/sequence.cljs b/frontend/src/arthur/domain/sequence.cljs index 8c3551a..3a14e6a 100644 --- a/frontend/src/arthur/domain/sequence.cljs +++ b/frontend/src/arthur/domain/sequence.cljs @@ -36,26 +36,60 @@ (recur (:parent n) (conj seen id) (conj chain n))))))) (defn- finish + "Commit `nodes` as symbol `sid`'s, or refuse. + + THE SHOT LENGTH IS AUTHORED. `:frames` is the symbol's window — how long the + shot IS — and the occupied extent of its lanes is a different fact derived + from the occurrences. A command may GROW the window when the caller says + `:grow-symbol`, and never shrinks it: emptying the end of a shot leaves a shot + with empty frames at the end, which is a true statement about what somebody + authored. Deriving the window from the extent instead would make deleting the + last drawing silently shorten the film. + + So there are two numbers and this function keeps them apart: `needed` is where + the occurrences reach, `:frames` is what was authored, and the only way the + second follows the first is a caller asking." [clip sid nodes selection extent] (let [sym (clip/symbol clip sid) - changed (for [[id n] nodes :when (node/sequence? n) - child (symbol/sequence-members nodes id) - :let [m (lane-map nodes id) - end (second (node/placed-span child))]] - (when m (+ (:at m) (/ end (:rate m))))) - end (apply max (:frames sym) (keep identity changed)) + reach (for [[id n] nodes :when (node/sequence? n) + child (symbol/sequence-members nodes id) + :let [m (lane-map nodes id) + end (second (node/placed-span child))]] + (when m (+ (:at m) (/ end (:rate m))))) + needed (js/Math.ceil (apply max 0 (keep identity reach))) ps (symbol/problems (assoc sym :nodes nodes))] (cond (seq ps) {:refused (first ps)} (not (#{:keep :grow-symbol} extent)) {:refused "choose an explicit shot-length policy"} - (and (> end (:frames sym)) (= :keep extent)) - {:refused (str "the edit needs " (js/Math.ceil end) " frames; extend the shot to continue") - :required-frames (js/Math.ceil end)} - :else {:clip (-> clip - (assoc-in [:symbols sid :nodes] nodes) - (assoc-in [:symbols sid :frames] (js/Math.ceil end))) + (and (> needed (:frames sym)) (= :keep extent)) + {:refused (str "the edit needs " needed " frames; extend the shot to continue") + :required-frames needed} + :else {:clip (cond-> (assoc-in clip [:symbols sid :nodes] nodes) + (> needed (:frames sym)) + (assoc-in [:symbols sid :frames] needed)) :selection selection}))) +;; --------------------------------------------------------------------------- +;; the geometry every exposure edit is made of +;; +;; A `:span` is in the occurrence's OWN frames and its `:time` says where those +;; land in the lane. So moving an edge of an exposure is one write to `:span`, +;; and `:time` and `:playback` are untouched — which is why trimming the front +;; of a playing insert starts it later in its source instead of resetting it, +;; and why the two halves of a split go on meaning what the one exposure meant. +;; Trim, split and blank are all this one operation, applied differently. + +(defn- local + "Lane frame `f` as one of `n`'s own frames." + [n f] + (let [{:keys [at rate]} (node/time-of n)] + (* rate (- f at)))) + +(defn- edged + "`n` with its `:in` or `:out` edge at lane frame `f`." + [n which f] + (assoc-in n [:span (case which :in 0 :out 1)] (local n f))) + (defn extend-hold "Change one held occurrence's duration by `delta` lane frames and ripple its later siblings. Lane channels, occurrence channels and source clocks stay put. @@ -116,13 +150,102 @@ (not (and lo (< lo cut hi))) {:refused (str "frame " cut " is not inside this exposure")} :else - (let [local (* rate (- cut at)) - nodes (-> nodes - (assoc-in [id :span 1] local) - (assoc new-id (assoc n :id new-id :z (str "a-" new-id) - :span [local (second (:span n))])))] + (let [nodes (-> nodes + (assoc id (edged n :out cut)) + (assoc new-id (assoc (edged n :in cut) + :id new-id :z (str "a-" new-id))))] (finish clip sid nodes new-id :keep))))) +(defn trim + "Move one edge of occurrence `id` to lane frame `to`, without disturbing a + single other occurrence. + + TRIM NARROWS. Lengthening an exposure is `extend-hold`, which carries a ripple + policy and a shot-length policy because it needs them; letting trim grow as + well would give one gesture two sets of rules and a way to overlap its + neighbour. `edge` is `:in` or `:out`. + + The source clock is untouched, so trimming the front of a playing insert + starts it later INTO its animation rather than restarting it — which is the + difference between trimming and slipping, and why they are separate commands." + [clip sid id edge to] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + [lo hi] (or (node/placed-span n) [nil nil])] + (cond + (not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"} + (not (#{:in :out} edge)) {:refused "an edge is :in or :out"} + (not (integer? to)) {:refused "an edge goes to a whole lane frame"} + (not (and lo (< lo to hi))) + {:refused (str "frame " to " is not inside this exposure; trim narrows it")} + :else (finish clip sid (assoc nodes id (edged n edge to)) id :keep)))) + +(defn move + "Put occurrence `id` at lane frame `to`, leaving every other occurrence and + its own length, source and corrections alone. + + One write to `:time :at`. A destination that would overlap a neighbour is + REFUSED rather than rippled or overwritten: moving a drawing and re-timing the + ones around it are different intentions, and a move that silently pushed the + rest would be the second one wearing the first one's name. Clear the room + first — `blank` makes a gap, `trim` shortens a neighbour." + [clip sid id to] + (let [nodes (get-in clip [:symbols sid :nodes]) + n (get nodes id) + lane (get nodes (:parent n)) + {:keys [at]} (node/time-of n)] + (cond + (not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"} + (not (integer? to)) {:refused "an exposure moves to a whole lane frame"} + (nil? (node/placed-span n)) {:refused "an occurrence needs a span to move"} + :else + (let [moved (update-in n [:time :at] (fnil + 0) (- to (first (node/placed-span n))))] + (if (not= to (first (node/placed-span moved))) + {:refused "exposure timing through a stepped or looping lane is not supported"} + (finish clip sid (assoc nodes id moved) id :keep)))))) + +(defn blank + "Clear lane frames `[a b)` of lane `lane-id`, leaving a GAP. + + A gap is not a drawing. Nothing is invented to cover those frames and nothing + closes the hole — the occurrences after it stay where they are, because + emptying frames and re-timing a performance are different intentions. + + What it does to each exposure it meets is the edge geometry above: one wholly + inside is removed, one overlapping an end is trimmed to it, and the one that + spans the whole range is split, which is the only case that needs `id`. Their + drawings stay in the library — a lane does not own its content, and a drawing + whose last exposure is gone is still a drawing somebody made." + [clip sid lane-id [a b] {:keys [id]}] + (let [nodes (get-in clip [:symbols sid :nodes]) + lane (get nodes lane-id) + members (when (node/sequence? lane) (symbol/sequence-members nodes lane-id)) + spanning (when members + (first (filter #(let [[lo hi] (node/placed-span %)] (and (< lo a) (> hi b))) + members)))] + (cond + (not (node/sequence? lane)) {:refused "select a sequence lane"} + (not (and (integer? a) (integer? b) (< a b))) + {:refused "a range to blank is whole lane frames, and not empty"} + (and spanning (or (nil? id) (contains? nodes id))) + {:refused "blanking inside one exposure splits it, which needs a free ID for the remainder"} + :else + (let [nodes (reduce + (fn [ns n] + (let [[lo hi] (node/placed-span n)] + (cond + (or (<= hi a) (>= lo b)) ns + (and (< lo a) (> hi b)) + (-> ns + (assoc (:id n) (edged n :out a)) + (assoc id (assoc (edged n :in b) :id id :z (str "a-" id)))) + (and (>= lo a) (<= hi b)) (dissoc ns (:id n)) + (< lo a) (assoc ns (:id n) (edged n :out a)) + :else (assoc ns (:id n) (edged n :in b))))) + nodes members)] + (finish clip sid nodes (or (when spanning id) lane-id) :keep))))) + (defn add-lane [clip sid id] (if (or (nil? (clip/symbol clip sid)) (get-in clip [:symbols sid :nodes id])) {:refused "the symbol is missing or the lane ID is already used"} diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 22db88d..68438c7 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -140,6 +140,49 @@ {:refused "this lane's frames are not the open symbol's"}) nil)))) +(defn- at-playhead + "The selected occurrence, its lane, and the playhead as a frame of that lane's + own time — or a refusal in place of the frame where there is no single one." + [db] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + n (get-in clip [:symbols sid :nodes id])] + {:clip clip :sid sid :id id :node n + :at (sequence/lane-frame clip sid (:parent n) (get-in db [:playback :frame]))})) + +(rf/reg-event-db + ::trim-exposure + (fn [db [_ edge]] + (let [{:keys [clip sid id at]} (at-playhead db)] + (apply-sequence-command + db sid (if at + (sequence/trim clip sid id edge at) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + +(rf/reg-event-db + ::move-exposure + (fn [db _] + (let [{:keys [clip sid id at]} (at-playhead db)] + (apply-sequence-command + db sid (if at + (sequence/move clip sid id at) + {:refused "this lane's frames are not the open symbol's"}) + nil)))) + +(rf/reg-event-db + ::blank-exposure + ;; The selected exposure's own frames, so the range needs no second gesture and + ;; the case that would split an exposure cannot arise. + (fn [db _] + (let [{:keys [clip sid node]} (at-playhead db) + span (node/placed-span node)] + (apply-sequence-command + db sid (if (and span (every? integer? span)) + (sequence/blank clip sid (:parent node) span {}) + {:refused "select an exposure that starts and ends on whole lane frames"}) + nil)))) + (rf/reg-event-db ::make-unique (fn [db [_ deep?]] diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index e484112..8906631 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -284,6 +284,18 @@ [:button {:disabled (not shared?) :title "give this exposure its own copy; other exposures keep sharing" :on-click #(rf/dispatch [::ui/make-unique])} "make unique"] + [:button {:disabled (not splittable?) + :title "start this exposure at the playhead; nothing else moves" + :on-click #(rf/dispatch [::ui/trim-exposure :in])} "trim in"] + [:button {:disabled (not splittable?) + :title "end this exposure at the playhead; nothing else moves" + :on-click #(rf/dispatch [::ui/trim-exposure :out])} "trim out"] + [:button {:disabled (not (and cel? (integer? at))) + :title "put this exposure at the playhead; refused if something is there" + :on-click #(rf/dispatch [::ui/move-exposure])} "move here"] + [:button {:disabled (not cel?) + :title "clear this exposure's frames, leaving a gap; later drawings stay put" + :on-click #(rf/dispatch [::ui/blank-exposure])} "blank"] [:button {:disabled (not held?) :title "shorten this exposure; ripple later drawings, keeping lane keys fixed" :on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"] diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs index 8138088..5e526a8 100644 --- a/frontend/test/arthur/domain/lane_test.cljs +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -484,3 +484,127 @@ (get-in moved [:symbols :main :nodes :b :channels])) "the layer itself was not touched by the retiming") (is (empty? (clip/problems moved))))) + +(defn- spans [clip ids] + (mapv #(node/placed-span (get-in clip [:symbols :main :nodes %])) ids)) + +(deftest trimming-narrows-one-exposure-and-moves-nothing-else + (let [doc (document) + r (sequence/trim doc :main :b :out 6) + after (:clip r)] + (is (= [[0 4] [4 6] [8 12]] (spans after [:a :b :insert]))) + (is (= :b (:selection r))) + (is (= (select-keys (get-in doc [:symbols :main :nodes :b]) [:time :playback :channels :source]) + (select-keys (get-in after [:symbols :main :nodes :b]) [:time :playback :channels :source])) + "only :span changed") + (is (= 12 (get-in after [:symbols :main :frames]))) + (is (empty? (clip/problems after))))) + +(deftest trimming-the-front-of-a-playing-insert-does-not-restart-it + ;; The difference between trimming and slipping. Its own frames are where they + ;; were, so the frames that survive show exactly what they showed. + (let [doc (document) + before (sample doc [10 11]) + after (:clip (sequence/trim doc :main :insert :in 10))] + (is (= [10 12] (node/placed-span (get-in after [:symbols :main :nodes :insert])))) + (is (= (:playback (get-in doc [:symbols :main :nodes :insert])) + (:playback (get-in after [:symbols :main :nodes :insert])))) + (is (= before (sample after [10 11])) "the same animation on the frames it kept") + ;; And the frames it gave up show nothing of it. + (is (= #{:plate} (set (keys (get (sample after [9]) 9))))))) + +(deftest trim-refuses-to-lengthen-or-to-land-on-an-edge + (let [doc (document)] + (doseq [[label edge to] [["at its own start" :in 4] + ["at its own end" :out 8] + ["past its end" :out 9] + ["before its start" :in 2] + ["off a whole frame" :out 5.5]]] + (is (:refused (sequence/trim doc :main :b edge to)) label)) + (is (:refused (sequence/trim doc :main :b :middle 6))) + (is (:refused (sequence/trim doc :main :girl :out 6)) "a lane is not an exposure"))) + +(deftest moving-an-exposure-keeps-its-length-and-its-source-origin + (let [doc (update-in (document) [:symbols :main :nodes] dissoc :b) + r (sequence/move doc :main :insert 4) + after (:clip r)] + (is (= [[0 4] [4 8]] (spans after [:a :insert]))) + (is (= :insert (:selection r))) + (is (= (:playback (get-in doc [:symbols :main :nodes :insert])) + (:playback (get-in after [:symbols :main :nodes :insert])))) + ;; It began on source frame 3 at lane 8; it begins on source frame 3 at lane 4. + (is (= (get-in (sample doc [8]) [8 [:insert :mark]]) + (get-in (sample after [4]) [4 [:insert :mark]]))) + (is (empty? (clip/problems after))))) + +(deftest a-move-onto-an-occupied-frame-is-refused-rather-than-rippled + (let [doc (document)] + (is (:refused (sequence/move doc :main :insert 6)) "it would overlap B") + (is (:refused (sequence/move doc :main :insert 4.5))) + (is (:refused (sequence/move doc :main :girl 2))) + ;; Clearing the room first is the composition, and then it goes. + (let [cleared (:clip (sequence/blank doc :main :girl [4 8] {}))] + (is (= [[0 4] [4 8]] (spans (:clip (sequence/move cleared :main :insert 4)) + [:a :insert])))))) + +(deftest blanking-leaves-a-gap-and-does-not-close-it + (let [doc (document) + r (sequence/blank doc :main :girl [5 7] {:id :rest}) + after (:clip r)] + ;; B spanned the range, so it became two exposures with a hole between them. + (is (= [[0 4] [4 5] [7 8] [8 12]] (spans after [:a :b :rest :insert]))) + (is (= :rest (:selection r))) + (let [at (sample after [4 5 6 7])] + (is (= #{:plate} (set (keys (at 5)))) "nothing is drawn on a blanked frame") + (is (= #{:plate} (set (keys (at 6))))) + (is (get-in at [4 [:b :mark]])) + (is (get-in at [7 [:rest :mark]]))) + (is (= 12 (get-in after [:symbols :main :frames]))) + (is (empty? (clip/problems after))))) + +(deftest blanking-a-whole-exposure-removes-it-and-keeps-its-drawing + (let [doc (document) + after (:clip (sequence/blank doc :main :girl [4 8] {}))] + (is (nil? (get-in after [:symbols :main :nodes :b]))) + (is (= [[0 4] [8 12]] (spans after [:a :insert])) "and moves nothing") + (is (= (get-in doc [:symbols :drawing-b]) (get-in after [:symbols :drawing-b])) + "a lane does not own its content") + (is (empty? (clip/problems after))))) + +(deftest blanking-a-range-trims-what-it-only-partly-covers + (let [doc (document) + after (:clip (sequence/blank doc :main :girl [3 9] {}))] + (is (= [[0 3] [9 12]] (spans after [:a :insert]))) + (is (nil? (get-in after [:symbols :main :nodes :b]))) + (is (= (get-in (sample doc [9]) [9 [:insert :mark]]) + (get-in (sample after [9]) [9 [:insert :mark]])) + "the insert kept its own frames, so frame 9 shows what it showed") + (is (empty? (clip/problems after))))) + +(deftest blank-refuses-what-it-cannot-do-in-one-piece + (let [doc (document)] + (is (re-find #"free ID" (:refused (sequence/blank doc :main :girl [5 7] {}))) + "splitting an exposure needs an ID for the remainder") + (is (:refused (sequence/blank doc :main :girl [5 7] {:id :a})) "and a free one") + (is (:refused (sequence/blank doc :main :girl [7 5] {}))) + (is (:refused (sequence/blank doc :main :girl [5 5] {}))) + (is (:refused (sequence/blank doc :main :girl [5 6.5] {}))) + (is (:refused (sequence/blank doc :main :plate [0 2] {}))))) + +(deftest the-shot-length-is-authored-and-emptying-a-lane-does-not-shorten-it + ;; The window and the occupied extent are two facts. A shot with nothing in + ;; the last half is a shot somebody authored that long, and deleting the last + ;; drawing must not quietly shorten the film. + (let [doc (document) + empty-lane (:clip (sequence/blank doc :main :girl [0 12] {}))] + (is (empty? (symbol/sequence-members (get-in empty-lane [:symbols :main :nodes]) :girl))) + (is (= 12 (get-in empty-lane [:symbols :main :frames]))) + (is (empty? (clip/problems empty-lane))) + ;; Growing is still the caller's word, and only ever grows. + (is (:refused (sequence/append-drawing empty-lane :main :girl :n :drawing-n {:at 20}))) + (is (= 21 (get-in (sequence/append-drawing empty-lane :main :girl :n :drawing-n + {:at 20 :extent :grow-symbol}) + [:clip :symbols :main :frames]))) + (is (= 12 (get-in (:clip (sequence/trim doc :main :insert :out 9)) + [:symbols :main :frames])) + "and trimming the last exposure leaves the window where it was"))) diff --git a/frontend/test/browser/sequence.mjs b/frontend/test/browser/sequence.mjs index d397031..48b1644 100644 --- a/frontend/test/browser/sequence.mjs +++ b/frontend/test/browser/sequence.mjs @@ -174,8 +174,31 @@ try { assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1, 'six exposures, still one row'); assert.equal(s.history.done.length, before.history.done.length + 5); + + // Trim, move and blank: three gestures that move nothing but their own + // exposure, and a shot whose length does not follow what is in it. + await evaluate(`[...document.querySelectorAll('.tl-cel')][2].click()`); + await sleep(200); + assert.deepEqual(placed(await shot()).slice(2, 4), [[3, 5], [5, 6]]); + await click('+1'); + assert.equal(await enabled('trim out'), true, 'the playhead is inside it'); + await click('trim out'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 4], [5, 6], [6, 7], [7, 8]], + 'it ends at the playhead and every other exposure stayed'); + assert.equal(await enabled('move here'), true); + await click('move here'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 3], [4, 5], [5, 6], [6, 7], [7, 8]], + 'and moves to the playhead, into the gap it just made'); + await click('blank'); + s = await shot(); + assert.deepEqual(placed(s), [[0, 2], [2, 3], [5, 6], [6, 7], [7, 8]], + 'blanked: a gap where it was, and nothing closed it'); + assert.equal(s.clip.symbols.main.frames, 20, 'the shot is as long as it was authored'); + assert.equal(s.history.done.length, before.history.done.length + 8); assert.equal(errors.length, 0, JSON.stringify(errors)); - console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate, split and insert; no server writes'); + console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate, split, insert, trim, move and blank; no server writes'); } finally { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));