A position is an argument, not another command

Everything could only be added to the end, because `append` computed its own
position — the max end of the lane — and so had no opinion to state. Insert is
not a new command; it is the argument that function was missing. `:at` takes a
lane frame or `:end`, `:end` is the position where nothing has to move, and
appending stops being a separate operation from inserting. New, reused and
duplicated drawings all take it, because there was only ever one placement rule.

Placing ripples: occurrences at or after the position move later by the new
exposure's duration, and `:keep` against `:grow-symbol` still decides what
happens at the shot's end. OVERWRITE is deliberately not a policy argument yet.
Taking frames away from the occurrence already there is TRIMMING, and an
argument whose second value is unimplemented is worse than an argument that is
not there. A position strictly inside an existing exposure refuses and names
`split`, rather than splitting on the quiet: one command performing two is how
a command stops being predictable.

Then split, which turned out to cost almost nothing, and that is the
interesting part. The two pieces keep ONE `:time` and differ only in `:span`.
The right piece's own frames therefore carry on exactly where the left's
stopped, so its source clock, its keys and its corrections go on meaning what
they meant: a held drawing holds the same frame either side of the cut, and a
playing insert plays through it without a seam. There is no arithmetic on
in-points to get wrong, and no shot-length question, since the pieces occupy
the frames the one exposure occupied. The test samples every frame before and
after and asserts the picture is identical — for a hold, for an exposure with a
correction of its own, and for a playing insert.

That is not a clever split. It is `:span` being in the node's OWN coordinates,
which was decided long before there were lanes, paying for something it was not
designed for. The same property is why extending a hold leaves lane keys alone.

Both new commands act at the playhead, which needed `lane-frame` — the symbol's
frame as a frame of the lane's own time, nil through a stepped or looping lane
where one is not the other. Nil refuses; it does not snap to a nearby frame.

Two smaller things found while doing it. `placeable` promised "a whole lane
frame" in its refusal and then accepted 2.5, so both it and `split` now require
an integer, as `extend-hold` already did for its delta. And `lane-end` is
private: `:end` is the only way to ask for it.

401 tests, 5,612 assertions. The browser flow now splits an exposure at the
playhead and puts a drawing in the gap, and checks that six exposures are still
one row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-30 15:56:26 -04:00
parent 9446829774
commit 26517af2fd
6 changed files with 296 additions and 42 deletions

View file

@ -1,9 +1,9 @@
# The Lane Model # The Lane Model
Revised 2026-09-30. Target design. Occurrence ownership, source playback, the Revised 2026-09-30. Target design. Occurrence ownership, source playback, the
content and exposure commands and a one-row cel strip are implemented; content and exposure commands, placement anywhere in a lane, and a one-row cel
correction layers, the range and retiming commands, and the remaining views are strip are implemented; correction layers, overwrite and the retiming commands,
not. See the status note under and the remaining views are not. See the status note under
[Proof obligations](#proof-obligations-and-implementation-order). [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). This revises the Claude artifact [The Lane Model](https://claude.ai/code/artifact/cd42981d-ed08-493f-94df-b7dd6657f0e6).
@ -442,23 +442,43 @@ The source-channel prototype has been removed: an occurrence names one symbol
and carries its own playback clock, and `node/problems` rejects the old 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 `[: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 other rules about a node map; `arthur.domain.sequence` holds the commands over
one — add lane, append drawing, reuse drawing, duplicate drawing, make unique, one — add lane, place a drawing (new, reused or duplicated), make unique, split,
and extend hold with explicit ripple and shot-length policy. Each is one history and extend hold. Each is one history step, and each refuses rather than
step, and each refuses rather than half-applying. The timeline draws a lane's half-applying. The timeline draws a lane's occurrences as cel blocks on the
occurrences as cel blocks on the lane's own row, and offers Make unique only lane's own row, and offers Make unique only where the selected exposure actually
where the selected exposure actually shares its drawing. shares its drawing.
There is ONE placement function and a position argument, so appending is not a
different operation from inserting: `:end` is a position like any other, the one
where nothing has to move. Placing ripples — occurrences at or after the
position move later by the new exposure's duration — and `:keep` versus
`:grow-symbol` still decides what happens at the shot's end. OVERWRITE is not a
policy argument yet, deliberately: taking frames away from the occurrence
already there is trimming, and until `trim` exists, placement that would need it
refuses instead of approximating it. A position inside an existing exposure
refuses too, and names `split` — one command does not quietly perform two.
Splitting turned out to cost almost nothing, which is evidence for the
representation rather than for the command. The two pieces keep ONE `:time` and
differ only in `:span`, so the right piece's own frames carry on where the
left's stopped and its source clock, keys and corrections go on meaning what
they meant — a held drawing holds the same frame either side, a playing insert
plays through the cut without a seam, and the test for it samples every frame
before and after and asserts the picture is identical. That falls out of `:span`
being in the node's own coordinates; it is not something split arranges.
Content copies are shallow by default and keep their references to other 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 symbols; `:deep? true` is the explicit copy that shares nothing, so the promise
of independence is only made where it is kept. of independence is only made where it is kept.
Correction layers, the range and retiming commands (blank, split, trim, move, Correction layers, overwrite, and the range and retiming commands (blank, trim,
slip source, retime) and the exposure-sheet view are not implemented; a refusal move, slip source, retime) and the exposure-sheet view are not implemented; a
is the current behavior where the model demands an explicit choice nobody has refusal is the current behavior where the model demands an explicit choice
made yet. The suite stands at 397 tests and 5,561 assertions, with 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, `frontend/test/browser/sequence.mjs` driving the editor through create, hold,
overflow, undo, reuse, make unique and duplicate. Rewrite tests that encode overflow, undo, reuse, make unique, duplicate, split and insert. Rewrite tests
superseded behavior rather than preserving behavior to keep them green. that encode superseded behavior rather than preserving behavior to keep them
green.
Build small adversarial documents and test their domain operations before Build small adversarial documents and test their domain operations before
expanding the interface: expanding the interface:

View file

@ -88,6 +88,41 @@
nodes later)] nodes later)]
(finish clip sid nodes id extent))))) (finish clip sid nodes id extent)))))
(defn split
"Cut occurrence `id` in two at lane frame `cut`. The left piece keeps its
identity; the right gets `new-id`.
NOTHING BUT `:span` DIFFERS between the two pieces. They keep one `:time`, so
the right piece's own frames carry on exactly where the left's stopped, and its
source clock, its keys and its corrections therefore go on meaning what they
meant before the cut — preserved by construction rather than by arithmetic on
in-points that could be wrong. A held drawing holds the same frame on both
sides; a playing insert plays on through the cut without a seam. That is what
`:span` being in the node's OWN coordinates buys, and it is why splitting
needs no shot-length policy: the pieces occupy the frames the one exposure
occupied.
The right piece is the selection, because it is the piece that was made."
[clip sid id cut new-id]
(let [nodes (get-in clip [:symbols sid :nodes])
n (get nodes id)
lane (get nodes (:parent n))
{:keys [at rate]} (node/time-of n)
[lo hi] (or (node/placed-span n) [nil nil])]
(cond
(not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"}
(not (integer? cut)) {:refused "a cut is a whole lane frame"}
(contains? nodes new-id) {:refused "the new occurrence ID is already used"}
(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))])))]
(finish clip sid nodes new-id :keep)))))
(defn add-lane [clip sid id] (defn add-lane [clip sid id]
(if (or (nil? (clip/symbol clip sid)) (get-in clip [:symbols sid :nodes 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"} {:refused "the symbol is missing or the lane ID is already used"}
@ -111,27 +146,65 @@
:span [0 1] :time {:at at :rate 1} :span [0 1] :time {:at at :rate 1}
:source {:symbol drawing-id} :playback {:in 0 :speed 0 :end :stop}}) :source {:symbol drawing-id} :playback {:in 0 :speed 0 :end :stop}})
(defn- append (defn lane-frame
"Put a held occurrence of `drawing-id` after everything already in `lane-id`. "Symbol frame `f` as a frame of lane `lane-id`'s OWN time, or nil through a
`:frame` in the result is where it lands, for a caller that wants to look at stepped or looping lane, where one frame of the symbol is not one frame of the
what it just made." lane and there is no single answer to give a command."
[clip sid lane-id id drawing-id extent] [clip sid lane-id f]
(when-let [{:keys [at rate]} (lane-map (get-in clip [:symbols sid :nodes]) lane-id)]
(* rate (- f at))))
(defn- lane-end
"Where lane `lane-id`'s occupied frames stop, in its own time."
[nodes lane-id]
(apply max 0 (map #(second (node/placed-span %))
(symbol/sequence-members nodes lane-id))))
(defn- place
"Put a held occurrence of `drawing-id` into `lane-id` at lane frame `at`, and
RIPPLE: everything starting at or after it moves later by its duration.
There is one placement function and `:end` is a position like any other, so
appending is not a different operation from inserting — the end is just where
nothing has to move. Overwriting is the other policy and is NOT this: taking
frames away from the occurrence already there is trimming, which is its own
command and not something placing a drawing should do on the quiet.
`:frame` in the result is where it landed, in the open symbol's time, for a
caller that wants to look at what it just made."
[clip sid lane-id id drawing-id at extent]
(let [nodes (get-in clip [:symbols sid :nodes]) (let [nodes (get-in clip [:symbols sid :nodes])
at (apply max 0 (map #(second (node/placed-span %)) at (if (= :end at) (lane-end nodes lane-id) at)
(symbol/sequence-members nodes lane-id))) n (held id lane-id drawing-id at)
result (finish clip sid (assoc nodes id (held id lane-id drawing-id at)) id extent) [lo hi] (node/placed-span n)
later (filter #(>= (first (node/placed-span %)) lo)
(symbol/sequence-members nodes lane-id))
nodes (reduce (fn [ns sibling]
(update-in ns [(:id sibling) :time :at] (fnil + 0) (- hi lo)))
(assoc nodes id n) later)
result (finish clip sid nodes id extent)
m (lane-map nodes lane-id)] m (lane-map nodes lane-id)]
(cond-> result (cond-> result
(:clip result) (assoc :frame (+ (:at m) (/ at (:rate m))))))) (:clip result) (assoc :frame (+ (:at m) (/ at (:rate m)))))))
(defn- appendable (defn- placeable
"Why a held occurrence cannot go into `lane-id`, or nil." "Why a held occurrence cannot go into `lane-id` at `at`, or nil."
[clip sid lane-id id] [clip sid lane-id id at]
(let [nodes (get-in clip [:symbols sid :nodes]) (let [nodes (get-in clip [:symbols sid :nodes])
lane (get nodes lane-id)] lane (get nodes lane-id)
;; INSIDE an exposure is not a position for another one. Splitting that
;; exposure is what makes it two, and doing it here would be one command
;; quietly performing two: the caller asks for `split` and then places.
inside (when (number? at)
(some (fn [n] (let [[lo hi] (node/placed-span n)]
(when (< lo at hi) n)))
(symbol/sequence-members nodes lane-id)))]
(cond (cond
(not (node/sequence? lane)) "select a sequence lane" (not (node/sequence? lane)) "select a sequence lane"
(contains? nodes id) "the new occurrence ID is already used" (contains? nodes id) "the new occurrence ID is already used"
(not (or (= :end at) (and (integer? at) (not (neg? at)))))
"a position is :end or a whole lane frame"
inside (str "frame " at " is inside an exposure; split it first")
(nil? (lane-map nodes lane-id)) "drawing creation through a stepped or looping lane is not supported" (nil? (lane-map nodes lane-id)) "drawing creation through a stepped or looping lane is not supported"
:else (first (symbol/sequence-problems nodes))))) :else (first (symbol/sequence-problems nodes)))))
@ -142,13 +215,13 @@
Fresh content, not a blank range: a lane with no occurrence over a frame shows Fresh content, not a blank range: a lane with no occurrence over a frame shows
nothing there already, and a drawing nobody has drawn in is a different thing nothing there already, and a drawing nobody has drawn in is a different thing
from a gap." from a gap."
[clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}] [clip sid lane-id id drawing-id {:keys [at extent] :or {extent :keep at :end}}]
(if-let [why (or (appendable clip sid lane-id id) (if-let [why (or (placeable clip sid lane-id id at)
(when (clip/symbol clip drawing-id) "the new drawing ID is already used"))] (when (clip/symbol clip drawing-id) "the new drawing ID is already used"))]
{:refused why} {:refused why}
(append (assoc-in clip [:symbols drawing-id] (place (assoc-in clip [:symbols drawing-id]
{:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) {:id drawing-id :name (name drawing-id) :frames 1 :nodes {}})
sid lane-id id drawing-id extent))) sid lane-id id drawing-id at extent)))
(defn reuse-drawing (defn reuse-drawing
"Append a held occurrence of content the document ALREADY has, so the same "Append a held occurrence of content the document ALREADY has, so the same
@ -157,15 +230,15 @@
This is the command `make-unique` is the undo of, and the reason they are two This is the command `make-unique` is the undo of, and the reason they are two
commands: reuse is a decision to share, and sharing is not something to commands: reuse is a decision to share, and sharing is not something to
discover later when an edit turns up somewhere else." discover later when an edit turns up somewhere else."
[clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}] [clip sid lane-id id drawing-id {:keys [at extent] :or {extent :keep at :end}}]
(if-let [why (or (appendable clip sid lane-id id) (if-let [why (or (placeable clip sid lane-id id at)
(when-not (clip/symbol clip drawing-id) "there is no such drawing to reuse") (when-not (clip/symbol clip drawing-id) "there is no such drawing to reuse")
;; Placing something that contains this symbol would close a ;; Placing something that contains this symbol would close a
;; loop, and a lane is no different from any other placement. ;; loop, and a lane is no different from any other placement.
(when (clip/contains-symbol? clip drawing-id sid) (when (clip/contains-symbol? clip drawing-id sid)
"a symbol cannot go inside itself"))] "a symbol cannot go inside itself"))]
{:refused why} {:refused why}
(append clip sid lane-id id drawing-id extent))) (place clip sid lane-id id drawing-id at extent)))
(defn- copied (defn- copied
"A copy of symbol `from`, as `{:clip :id}`. "A copy of symbol `from`, as `{:clip :id}`.
@ -192,15 +265,15 @@
rather than a copy of `id`'s own transform or corrections: those belong to rather than a copy of `id`'s own transform or corrections: those belong to
that exposure, and carrying them over would make duplicating a drawing quietly that exposure, and carrying them over would make duplicating a drawing quietly
duplicate the treatment of one use of it." duplicate the treatment of one use of it."
[clip sid id new-id {:keys [extent deep?] :or {extent :keep}}] [clip sid id new-id {:keys [at extent deep?] :or {extent :keep at :end}}]
(let [n (get-in clip [:symbols sid :nodes id]) (let [n (get-in clip [:symbols sid :nodes id])
from (node/source n)] from (node/source n)]
(if-let [why (or (when-not from "select an occurrence to duplicate") (if-let [why (or (when-not from "select an occurrence to duplicate")
(when-not (clip/symbol clip from) "the drawing it places is missing") (when-not (clip/symbol clip from) "the drawing it places is missing")
(appendable clip sid (:parent n) new-id))] (placeable clip sid (:parent n) new-id at))]
{:refused why} {:refused why}
(let [{c :clip copy :id} (copied clip from deep?)] (let [{c :clip copy :id} (copied clip from deep?)]
(append c sid (:parent n) new-id copy extent))))) (place c sid (:parent n) new-id copy at extent)))))
(defn make-unique (defn make-unique
"Point occurrence `id` at a private copy of its content, leaving every other "Point occurrence `id` at a private copy of its content, leaving every other

View file

@ -61,9 +61,10 @@
(defn- committed (defn- committed
"One appending command, as effects: commit it, and look at what it made. "One appending command, as effects: commit it, and look at what it made.
Seeking is the whole reason these are `-fx` events. A new exposure lands after Seeking is the whole reason these are `-fx` events. An appended exposure lands
everything already in the lane, which is usually off the playhead, and a past the end of the lane, off the playhead, and a drawing you cannot see is not
drawing you cannot see is not one you can draw in." one you can draw in. An inserted one is already under the playhead and the
seek is a no-op, which is the same rule and not a second one."
[db sid result retry] [db sid result retry]
(let [{clip :clip st :store} (store/entry (:clip/current db)) (let [{clip :clip st :store} (store/entry (:clip/current db))
path (nth (get-in db [:ui :selection]) 3 nil) path (nth (get-in db [:ui :selection]) 3 nil)
@ -110,6 +111,35 @@
{:extent (or extent :keep) :deep? deep?})] {:extent (or extent :keep) :deep? deep?})]
(committed db sid result [::duplicate-drawing :grow-symbol deep?])))) (committed db sid result [::duplicate-drawing :grow-symbol deep?]))))
(rf/reg-event-fx
::insert-drawing
;; The playhead is the position: you scrub to where the drawing goes. A lane
;; that is stepped or retimed off whole frames has no single lane frame for a
;; symbol frame, and `lane-frame` says so rather than snapping to one.
(fn [{:keys [db]} [_ extent]]
(let [clip (:clip (store/entry (:clip/current db)))
[_ sid id] (get-in db [:ui :selection])
lane (selected-lane clip sid id)
at (sequence/lane-frame clip sid lane (get-in db [:playback :frame]))
result (if at
(sequence/append-drawing clip sid lane (random-uuid) (clip/fresh-id clip)
{:at at :extent (or extent :keep)})
{:refused "this lane's frames are not the open symbol's"})]
(committed db sid result [::insert-drawing :grow-symbol]))))
(rf/reg-event-db
::split-exposure
(fn [db _]
(let [clip (:clip (store/entry (:clip/current db)))
[_ sid id] (get-in db [:ui :selection])
cut (sequence/lane-frame clip sid (:parent (get-in clip [:symbols sid :nodes id]))
(get-in db [:playback :frame]))]
(apply-sequence-command
db sid (if cut
(sequence/split clip sid id cut (random-uuid))
{:refused "this lane's frames are not the open symbol's"})
nil))))
(rf/reg-event-db (rf/reg-event-db
::make-unique ::make-unique
(fn [db [_ deep?]] (fn [db [_ deep?]]

View file

@ -23,6 +23,7 @@
(:require [clojure.string :as str] (:require [clojure.string :as str]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.sequence :as sequence]
[arthur.domain.symbol :as symbol] [arthur.domain.symbol :as symbol]
[arthur.domain.trace :as trace] [arthur.domain.trace :as trace]
[arthur.events.playback :as pb] [arthur.events.playback :as pb]
@ -236,7 +237,13 @@
shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip) shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip)
[_ other] (:nodes sym) [_ other] (:nodes sym)
:when (= (node/source n) (node/source other))] :when (= (node/source n) (node/source other))]
other))))] other))))
;; Both act at the playhead, so both are offered only where the playhead
;; is somewhere they mean something.
at (when lane? (sequence/lane-frame clip sid (:id lane) frame))
insertable? (and lane? (integer? at))
splittable? (and cel? (integer? at)
(let [[lo hi] (node/placed-span n)] (< lo at hi)))]
[:div.pane-head [:div.pane-head
[:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")] [:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")]
[:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"] [:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"]
@ -262,6 +269,12 @@
[:button {:disabled (not lane?) [:button {:disabled (not lane?)
:title "append a new independent drawing to the selected lane" :title "append a new independent drawing to the selected lane"
:on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"] :on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"]
[:button {:disabled (not insertable?)
:title "a new drawing at the playhead; later drawings ripple later"
:on-click #(rf/dispatch [::ui/insert-drawing])} "insert"]
[:button {:disabled (not splittable?)
:title "cut this exposure in two at the playhead; the picture does not change"
:on-click #(rf/dispatch [::ui/split-exposure])} "split"]
[:button {:disabled (not cel?) [:button {:disabled (not cel?)
:title "expose this same drawing again — one drawing, two exposures" :title "expose this same drawing again — one drawing, two exposures"
:on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"] :on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"]

View file

@ -334,3 +334,96 @@
(is (= [0 10 20 30 40 50] (is (= [0 10 20 30 40 50]
(mapv (fn [f] (let [[[id _] cx] (showing f)] (- cx (xs id)))) (range 6))) (mapv (fn [f] (let [[[id _] cx] (showing f)] (- cx (xs id)))) (range 6)))
"and the lane moves on every frame, odd ones included"))) "and the lane moves on every frame, odd ones included")))
(defn- drawn
"What every frame draws, as sorted values, so a picture can be compared
without naming the occurrences that produced it."
[doc fs]
(let [at (sample doc fs)]
(mapv #(sort (vals (get at %))) fs)))
(deftest a-drawing-goes-anywhere-in-the-lane-and-ripples-what-follows
(let [doc (document)
keys-of #(get-in % [:symbols :main :nodes :girl :channels [:xform :pos] :keys])
spans #(mapv (fn [id] (node/placed-span (get-in % [:symbols :main :nodes id])))
[:a :n :b :insert])
r (sequence/append-drawing doc :main :girl :n :drawing-n
{:at 4 :extent :grow-symbol})]
(is (= [[0 4] [4 5] [5 9] [9 13]] (spans (:clip r))))
(is (= 13 (get-in r [:clip :symbols :main :frames])))
(is (= (keys-of doc) (keys-of (:clip r))) "lane keys stay where they were authored")
(is (= :n (:selection r)))
(is (= 4 (:frame r)))
(is (empty? (clip/problems (:clip r))))
;; The same command with no room refuses, and says how much it needs.
(is (= 13 (:required-frames (sequence/append-drawing doc :main :girl :n :drawing-n {:at 4}))))
;; At the very front everything moves.
(is (= [[1 5] [0 1] [5 9] [9 13]]
(spans (:clip (sequence/append-drawing doc :main :girl :n :drawing-n
{:at 0 :extent :grow-symbol})))))
;; Inside an exposure is not a position for another one.
(is (re-find #"split it first"
(:refused (sequence/append-drawing doc :main :girl :n :drawing-n
{:at 2 :extent :grow-symbol}))))
(is (:refused (sequence/append-drawing doc :main :girl :n :drawing-n
{:at -1 :extent :grow-symbol})))
(is (:refused (sequence/append-drawing doc :main :girl :n :drawing-n
{:at ##Inf :extent :grow-symbol})))
;; Reuse and duplicate take a position too; it is one placement rule.
(is (= [4 5] (node/placed-span
(get-in (sequence/reuse-drawing doc :main :girl :n :drawing-b
{:at 4 :extent :grow-symbol})
[:clip :symbols :main :nodes :n]))))
(is (= [4 5] (node/placed-span
(get-in (sequence/duplicate-drawing doc :main :b :n
{:at 4 :extent :grow-symbol})
[:clip :symbols :main :nodes :n]))))))
(deftest splitting-an-exposure-changes-nothing-that-is-drawn
(let [doc (document)
fs (range 12)
before (drawn doc fs)]
(doseq [[label id cut] [["a held drawing" :a 2]
["an exposure with a correction of its own" :b 6]
["a playing insert" :insert 10]]]
(testing label
(let [r (sequence/split doc :main id cut :right)
after (:clip r)]
(is (= :right (:selection r)))
(is (= before (drawn after fs)) "the same picture, frame for frame")
(is (= (node/placed-span (get-in doc [:symbols :main :nodes id]))
[(first (node/placed-span (get-in after [:symbols :main :nodes id])))
(second (node/placed-span (get-in after [:symbols :main :nodes :right])))])
"the pieces occupy the frames the exposure did")
(is (= cut (second (node/placed-span (get-in after [:symbols :main :nodes id])))
(first (node/placed-span (get-in after [:symbols :main :nodes :right])))))
(is (= (:time (get-in doc [:symbols :main :nodes id]))
(:time (get-in after [:symbols :main :nodes :right])))
"one time map, so the right piece's own frames carry on")
(is (= (select-keys (get-in doc [:symbols :main :nodes id]) [:source :playback :channels])
(select-keys (get-in after [:symbols :main :nodes :right]) [:source :playback :channels])))
(is (= 12 (get-in after [:symbols :main :frames])) "and no shot-length question")
(is (empty? (clip/problems after))))))))
(deftest split-refuses-anything-that-is-not-one-cut-inside-one-exposure
(let [doc (document)]
(doseq [cut [0 4 8 12 -1 2.5 ##NaN nil]]
(is (:refused (sequence/split doc :main :b cut :right)) (str "cut at " (pr-str cut))))
(is (:refused (sequence/split doc :main :girl 2 :right)) "a lane is not an exposure")
(is (:refused (sequence/split doc :main :plate 2 :right)) "nor is a shape outside one")
(is (:refused (sequence/split doc :main :a 2 :b)) "the new ID has to be free")))
(deftest split-then-place-puts-a-drawing-inside-a-hold
;; The two commands the doc asks for, composed: neither one guesses.
(let [doc (document)
cut (:clip (sequence/split doc :main :a 2 :right))
r (sequence/append-drawing cut :main :girl :n :drawing-n
{:at 2 :extent :grow-symbol})
after (:clip r)]
(is (= [[0 2] [2 3] [3 5] [5 9] [9 13]]
(mapv #(node/placed-span (get-in after [:symbols :main :nodes %]))
[:a :n :right :b :insert])))
(is (= (get-in doc [:symbols :main :nodes :girl :channels])
(get-in after [:symbols :main :nodes :girl :channels]))
"the performance is still timed the way it was authored")
(is (empty? (clip/problems after)))))

View file

@ -149,8 +149,33 @@ try {
assert.equal(new Set(cels.map(n => n.source.symbol)).size, 4, assert.equal(new Set(cels.map(n => n.source.symbol)).size, 4,
'four exposures of four drawings: nothing is shared once every copy is made'); 'four exposures of four drawings: nothing is shared once every copy is made');
assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps'); assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps');
// A drawing into the middle of a hold: split, then insert. Both act at the
// playhead, and neither guesses what the other one is for.
const placed = s => instances(s)
.map(n => [n.time.at + n.span[0] / (n.time.rate ?? 1), n.time.at + n.span[1] / (n.time.rate ?? 1)])
.sort((a, b) => a[0] - b[0]);
assert.deepEqual(placed(s), [[0, 4], [4, 5], [5, 6], [6, 7]]);
await evaluate(`document.querySelector('.tl-cel').click()`);
await sleep(200);
assert.equal(await enabled('split'), false, 'the start of an exposure is not inside it');
await click('+1');
await click('+1');
assert.equal(await enabled('split'), true);
await click('split');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 4], [4, 5], [5, 6], [6, 7]],
'one exposure became two, over the frames it had');
await click('insert');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 5], [5, 6], [6, 7], [7, 8]],
'the new drawing took frame 2 and everything from there rippled later');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 6);
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);
assert.equal(errors.length, 0, JSON.stringify(errors)); 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; no server writes'); 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');
} finally { } finally {
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' })); ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));