Turn the lane sideways without changing what it means

The cel sheet is a second projection of the same lane rows: frames run down, lanes run across, and each occupied cell carries the timeline cel's exact selection address. The shared action strip proves the point in the browser test by selecting a cell and issuing the existing hold command.

Before exposing that second entrance, fix the boundary mistakes it revealed. Nested commands now convert the open playhead through their enclosing instance path. Overwrite composes blanking with non-rippling placement as one transaction. Picture-rate and pose sampling select only the generated base frame while hand corrections retain the node's authored frame. Stack validation follows covering replacement layers so a document accepted by the validator cannot throw solely because a later offset sees a different shape.

429 tests, 5,767 assertions; both browser flows; 56 Django tests; optimized frontend build.
This commit is contained in:
Your Name 2026-09-30 19:59:38 -04:00
parent 7a54bfca56
commit 2f1c9b9c02
15 changed files with 387 additions and 100 deletions

View file

@ -160,6 +160,7 @@
:ui {:open nil
:tabs []
:selection nil
:time-view :timeline
:tone :skin-base
:tool nil
:draft []

View file

@ -154,6 +154,16 @@
(seq (:keys ch)) (shape (val (first (:keys ch))))
:else nil))
(defn- shape-conflict [base-shape correction-shape]
(cond
(or (nil? base-shape) (nil? correction-shape)) nil
(= :opaque base-shape) "the base is not a number or a row of components"
(= :opaque correction-shape) "the correction is not a number or a row of components"
(not= base-shape correction-shape)
(str "the base has " (pr-str base-shape) " and the correction "
(pr-str correction-shape) " — a correction cannot offset a value of"
" a different shape")))
(defn conflict-with
"Why correction `l` cannot apply to base channel `base`, or nil.
@ -166,14 +176,29 @@
disagreement, it is a channel with nothing in it."
[base l]
(when (= :offset (:op l))
(let [b (value-shape base) v (value-shape (:values l))]
(cond
(or (nil? b) (nil? v)) nil
(= :opaque b) "the base is not a number or a row of components"
(= :opaque v) "the correction is not a number or a row of components"
(not= b v) (str "the base has " (pr-str b) " and the correction "
(pr-str v) " — a correction cannot offset a value of"
" a different shape")))))
(shape-conflict (value-shape base) (value-shape (:values l)))))
(defn- stack-conflict
"Why layer `i` can encounter a value of the wrong shape after the layers
before it. A replace covering all of this layer's support becomes the only
possible input; a partly overlapping replace adds another possible input."
[ch i l]
(when (and (= :offset (:op l))
(vector? (:support l)) (= 2 (count (:support l))))
(let [[a b] (:support l)
shapes (reduce
(fn [possible prior]
(let [[c d] (when (and (vector? (:support prior))
(= 2 (count (:support prior))))
(:support prior))]
(if (and c d (not (:conflict prior)) (= :replace (:op prior))
(< a d) (< c b))
(let [s (value-shape (:values prior))]
(if (and (<= c a) (<= b d)) #{s} (conj possible s)))
possible)))
#{(value-shape ch)} (take i (:over ch)))
v (value-shape (:values l))]
(some #(shape-conflict % v) shapes))))
(defn conflicts
"Corrections on `ch` that cannot apply to its base, as `[{:id :why}]`.
@ -330,18 +355,20 @@
dense, and throw the first time a selection landed on one that was. That is
how `gesture/values` took the stage down on an iris. A caller with no store
says `nil` and means it."
[ch f store]
(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)))
([ch f store] (value-at ch f f store))
([ch base-f correction-f store]
(let [base (cond
(not (:animated? ch)) (:value ch)
(:dense ch) (dense-at (:dense ch) base-f store nil)
(:keys ch) (let [ks (:keys ch)]
(if (empty? ks) absent (keyed-at ch base-f)))
:else
(throw (ex-info "animated channel has neither :keys nor :dense"
{:channel ch})))]
(if (seq (:over ch))
(over-at ch correction-f base
(fn [_ values f] (value-at values f store)))
base))))
;; ---------------------------------------------------------------------------
;; the playback path
@ -428,12 +455,14 @@
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)))
([cur f] (sample! cur f f))
([^Cursor cur base-f correction-f]
(let [ch (.-ch cur)
base (base-sample! cur ch (.-ks cur) base-f)]
(if (seq (:over ch))
(over-at ch correction-f base
(fn [i _ f] (sample! (nth (.-overs cur) i) f)))
base))))
;; ---------------------------------------------------------------------------
@ -527,9 +556,9 @@
;; reports those. The distinction is what keeps a topology change from
;; making a document that will not load.
(and (map? ch) (vector? (:over ch)))
(into (for [l (:over ch)
(into (for [[i l] (map-indexed vector (:over ch))
:when (not (:conflict l))
:let [why (conflict-with ch l)]
:let [why (stack-conflict ch i l)]
:when why]
(str "correction " (pr-str (:id l)) " " why)))

View file

@ -295,13 +295,14 @@
`: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]
[clip sid lane-id id drawing-id at extent ripple?]
(let [nodes (get-in clip [:symbols sid :nodes])
at (if (= :end at) (lane-end nodes lane-id) at)
n (held id lane-id drawing-id at)
[lo hi] (node/placed-span n)
later (filter #(>= (first (node/placed-span %)) lo)
(symbol/lane-cels nodes lane-id))
later (when ripple?
(filter #(>= (first (node/placed-span %)) lo)
(symbol/lane-cels nodes lane-id)))
nodes (reduce (fn [ns sibling]
(update-in ns [(:id sibling) :time :at] (fnil + 0) (- hi lo)))
(assoc nodes id n) later)
@ -344,7 +345,7 @@
{:refused why}
(place (assoc-in clip [:symbols drawing-id]
{:id drawing-id :name (name drawing-id) :frames 1 :nodes {}})
sid lane-id id drawing-id at extent)))
sid lane-id id drawing-id at extent true)))
(defn reuse-drawing
"Append a held cel of content the document ALREADY has, so the same
@ -361,7 +362,7 @@
(when (clip/contains-symbol? clip drawing-id sid)
"a symbol cannot go inside itself"))]
{:refused why}
(place clip sid lane-id id drawing-id at extent)))
(place clip sid lane-id id drawing-id at extent true)))
(defn- copied
"A copy of symbol `from`, as `{:clip :id}`.
@ -396,7 +397,34 @@
(placeable clip sid (:parent n) new-id at))]
{:refused why}
(let [{c :clip copy :id} (copied clip from deep?)]
(place c sid (:parent n) new-id copy at extent)))))
(place c sid (:parent n) new-id copy at extent true)))))
(defn overwrite-drawing
"Put a fresh one-frame drawing at lane frame `at`, replacing whatever was
there and leaving every other cel where it was.
This is `blank` and placement composed in ONE command and therefore one undo
step. `remainder-id` is used only when clearing the frame cuts one cel into
two; ids still come from the caller because this namespace is pure."
[clip sid lane-id id drawing-id at {:keys [extent remainder-id]
:or {extent :keep}}]
(let [nodes (get-in clip [:symbols sid :nodes])]
(if-let [why (cond
(not (and (integer? at) (not (neg? at))))
"a position is a nonnegative whole lane frame"
(contains? nodes id) "the new cel ID is already used"
(or (= id remainder-id) (contains? nodes remainder-id))
"the remainder cel needs a free ID different from the new cel"
(clip/symbol clip drawing-id) "the new drawing ID is already used"
(nil? (lane-map nodes lane-id))
"drawing creation through a stepped or looping lane is not supported")]
{:refused why}
(let [cleared (blank clip sid lane-id [at (inc at)] {:id remainder-id})]
(if (:refused cleared)
cleared
(place (assoc-in (:clip cleared) [:symbols drawing-id]
{:id drawing-id :name (name drawing-id) :frames 1 :nodes {}})
sid lane-id id drawing-id at extent false))))))
(defn make-unique
"Point cel `id` at a private copy of its content, leaving every other

View file

@ -396,7 +396,7 @@
{:keys (vec (sort-by str (keys sym)))})))
(into {} (remove #(= :audio (:kind (val %)))) nodes)))
(defn- channel-frame
(defn- base-channel-frame
"A trace selects the measured frames its node reads; marked channels read
instance pose choices."
[choices traces nodes source-fps picture-fps id c lf]
@ -471,9 +471,9 @@
traces (prepared-traces nodes)
ord (order nodes)]
(eval-into {:read (fn [id path c lf]
(ch/value-at c (channel-frame choices traces nodes
source-fps picture-fps id c lf)
store))
(ch/value-at c (base-channel-frame choices traces nodes
source-fps picture-fps id c lf)
lf store))
:palette palette
:mat-for (fn [_id] (node/mat))
:pinv-for (fn [id] (node/pinv (get nodes id)))
@ -559,8 +559,9 @@
ctx {:read (fn [id path c lf]
(when-let [cursor (get-in cursors [id path])]
(ch/sample! cursor
(channel-frame choices traces nodes
source-fps picture-fps id c lf))))
(base-channel-frame choices traces nodes
source-fps picture-fps id c lf)
lf)))
:palette palette
:mat-for (fn [id] (get mats id))
:on-place (fn [id p] (vswap! placed assoc id p))

View file

@ -35,6 +35,13 @@
::set-tone
(fn [db [_ tone]] (assoc-in db [:ui :tone] tone)))
(rf/reg-event-db
::set-time-view
(fn [db [_ view]]
(if (#{:timeline :cel-sheet} view)
(assoc-in db [:ui :time-view] view)
db)))
(defn apply-lane-command
"Commit a successful domain command as one history step. A refused command
leaves the document and history untouched; an overflow offers an explicit retry."
@ -82,6 +89,18 @@
(let [n (get-in clip [:symbols sid :nodes id])]
(if (node/lane? n) id (:parent n))))
(defn selection-frame
"The playhead as a frame of the symbol that owns `selection`.
A timeline selection carries its path from the open symbol. Walking to the
parent of the selected node crosses every enclosing instance clock before a
lane command converts that owning-symbol frame into lane time."
[clip st open selection frame]
(let [[_ sid _ path] selection]
(if (or (= sid open) (not (seq path)))
frame
(:frame (nest/inside clip st open (pop path) frame)))))
(rf/reg-event-fx
::append-drawing
(fn [{:keys [db]} [_ extent]]
@ -117,10 +136,13 @@
;; 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])
(let [{clip :clip st :store} (store/entry (:clip/current db))
selection (get-in db [:ui :selection])
[_ sid id] selection
lane (selected-lane clip sid id)
at (lane/lane-frame clip sid lane (get-in db [:playback :frame]))
owner-frame (selection-frame clip st (get-in db [:ui :open]) selection
(get-in db [:playback :frame]))
at (when (number? owner-frame) (lane/lane-frame clip sid lane owner-frame))
result (if at
(lane/append-drawing clip sid lane (random-uuid) (clip/fresh-id clip)
{:at at :extent (or extent :keep)})
@ -130,10 +152,14 @@
(rf/reg-event-db
::split-cel
(fn [db _]
(let [clip (:clip (store/entry (:clip/current db)))
[_ sid id] (get-in db [:ui :selection])
cut (lane/lane-frame clip sid (:parent (get-in clip [:symbols sid :nodes id]))
(get-in db [:playback :frame]))]
(let [{clip :clip st :store} (store/entry (:clip/current db))
selection (get-in db [:ui :selection])
[_ sid id] selection
owner-frame (selection-frame clip st (get-in db [:ui :open]) selection
(get-in db [:playback :frame]))
cut (when (number? owner-frame)
(lane/lane-frame clip sid (:parent (get-in clip [:symbols sid :nodes id]))
owner-frame))]
(apply-lane-command
db sid (if cut
(lane/split clip sid id cut (random-uuid))
@ -144,11 +170,31 @@
"The selected cel, 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])
(let [{clip :clip st :store} (store/entry (:clip/current db))
selection (get-in db [:ui :selection])
[_ sid id] selection
n (get-in clip [:symbols sid :nodes id])]
{:clip clip :sid sid :id id :node n
:at (lane/lane-frame clip sid (:parent n) (get-in db [:playback :frame]))}))
:at (when-let [owner-frame (selection-frame clip st (get-in db [:ui :open]) selection
(get-in db [:playback :frame]))]
(lane/lane-frame clip sid (:parent n) owner-frame))}))
(rf/reg-event-fx
::overwrite-drawing
(fn [{:keys [db]} [_ extent]]
(let [{clip :clip st :store} (store/entry (:clip/current db))
selection (get-in db [:ui :selection])
[_ sid id] selection
lane-id (selected-lane clip sid id)
owner-frame (selection-frame clip st (get-in db [:ui :open]) selection
(get-in db [:playback :frame]))
at (when (number? owner-frame) (lane/lane-frame clip sid lane-id owner-frame))
result (if (integer? at)
(lane/overwrite-drawing clip sid lane-id (random-uuid) (clip/fresh-id clip) at
{:extent (or extent :keep)
:remainder-id (random-uuid)})
{:refused "this lane's frames are not the open symbol's"})]
(committed db sid result [::overwrite-drawing :grow-symbol]))))
(rf/reg-event-db
::trim-cel

View file

@ -12,6 +12,7 @@
[re-frame.core :as rf]))
(rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection])))
(rf/reg-sub ::time-view (fn [db _] (get-in db [:ui :time-view] :timeline)))
(rf/reg-sub ::lane-retry (fn [db _] (get-in db [:ui :lane-retry])))
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone])))
(rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool])))

View file

@ -28,6 +28,7 @@
[arthur.domain.trace :as trace]
[arthur.events.playback :as pb]
[arthur.events.ui :as ui]
[arthur.footage.store :as store]
[arthur.subs.playback :as playback]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
@ -226,7 +227,11 @@
frames @(rf/subscribe [::render/frames])
{:keys [fps drop]} @player/meter
clip @(rf/subscribe [::render/clip])
[_ sid id] @(rf/subscribe [::sub/selection])
clip-id @(rf/subscribe [::render/clip-id])
selection @(rf/subscribe [::sub/selection])
[_ sid id] selection
open @(rf/subscribe [::render/open])
st (:store (store/entry clip-id))
n (get-in clip [:symbols sid :nodes id])
lane (if (node/lane? n) n (get-in clip [:symbols sid :nodes (:parent n)]))
lane? (node/lane? lane)
@ -240,7 +245,9 @@
other))))
;; Both act at the playhead, so both are offered only where the playhead
;; is somewhere they mean something.
at (when lane? (lane/lane-frame clip sid (:id lane) frame))
owner-frame (ui/selection-frame clip st open selection frame)
at (when (and lane? (number? owner-frame))
(lane/lane-frame clip sid (:id lane) owner-frame))
insertable? (and lane? (integer? at))
splittable? (and cel? (integer? at)
(let [[lo hi] (node/placed-span n)] (< lo at hi)))]
@ -253,6 +260,10 @@
:on-click #(rf/dispatch [::pb/toggle-loop])} "loop"]
[:button {:class (when @(rf/subscribe [::playback/muted?]) "on")
:on-click #(rf/dispatch [::pb/toggle-mute])} "mute"]
[:button {:class (when (= :timeline @(rf/subscribe [::sub/time-view])) "on")
:on-click #(rf/dispatch [::ui/set-time-view :timeline])} "timeline"]
[:button {:class (when (= :cel-sheet @(rf/subscribe [::sub/time-view])) "on")
:on-click #(rf/dispatch [::ui/set-time-view :cel-sheet])} "cel sheet"]
(doall
(for [r [0.25 0.5 1.0 2.0 4.0]]
^{:key r}
@ -272,6 +283,9 @@
[: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 insertable?)
:title "replace the drawing at the playhead; later drawings stay put"
:on-click #(rf/dispatch [::ui/overwrite-drawing])} "overwrite"]
[:button {:disabled (not splittable?)
:title "cut this cel in two at the playhead; the picture does not change"
:on-click #(rf/dispatch [::ui/split-cel])} "split"]
@ -492,7 +506,7 @@
(for [f keys :when (and (<= 0 f) (< f frames))]
^{:key f} [:div.tl-key {:style {:left (at% f frames)}}])))]))
(defn view []
(defn- timeline-view []
(r/with-let [scrubbing (r/atom false)
;; The row a carried row is over and which part of it, for the
;; highlight.
@ -603,3 +617,56 @@
{:key (str (:path row))})))
[:div.tl-empty "nothing in this symbol"])
[:div.tl-playhead {:style {:left (at% frame frames)}}]]]])))
(defn cel-sheet
"The cel-sheet projection of one symbol: frames down, one column per lane.
It reuses `rows`, so its spans and selection addresses are exactly the ones
the timeline presents rather than a second interpretation of the document."
[clip sid frames]
(mapv (fn [{:keys [path label cels]}]
{:id (peek path)
:label label
:cells (mapv (fn [f]
(let [cel (some (fn [{[in out] :span :as cel}]
(when (and (<= in f) (< f out)) cel))
cels)]
{:frame f :lane (peek path) :cel cel}))
(range frames))})
(filter :cels (rows clip sid #{}))))
(defn- cel-sheet-view []
(let [clip @(rf/subscribe [::render/clip])
sid @(rf/subscribe [::render/open])
frames (max 1 (or @(rf/subscribe [::render/frames]) 1))
frame @(rf/subscribe [::playback/frame])
selection @(rf/subscribe [::sub/selection])
columns (cel-sheet clip sid frames)
style {:grid-template-columns
(str "52px repeat(" (max 1 (count columns)) ", minmax(110px, 1fr))")}]
[:div.cel-sheet {:style style}
[:div.cs-head.cs-frame "frame"]
(doall (for [{:keys [id label]} columns]
^{:key (str "head-" id)} [:div.cs-head label]))
(doall
(for [f (range frames)
item (cons {:frame-label? true}
(map #(get-in % [:cells f]) columns))]
(if (:frame-label? item)
^{:key (str "frame-" f)}
[:button.cs-frame {:class (when (= f frame) "on")
:on-click #(rf/dispatch [::pb/seek f])} f]
(let [{:keys [id label select]} (:cel item)]
^{:key (str f "-" (:lane item) "-" (or id "gap"))}
[:button.cs-cell
{:class (str (when (= f frame) " current")
(when (and select (= select selection)) " selected"))
:title (if id (str label " · frame " f) (str "gap · frame " f))
:on-click (fn []
(rf/dispatch [::pb/seek f])
(when select (rf/dispatch [::ui/select select])))}
(or label "—")]))))]))
(defn view []
(if (= :cel-sheet @(rf/subscribe [::sub/time-view]))
[:section.pane.time [transport] [cel-sheet-view]]
[timeline-view]))

View file

@ -276,6 +276,26 @@
(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 validation-follows-the-shape-produced-by-an-ordered-stack
(let [base (ch/framed [0 0])
put3 (ch/layer :put3 [0 2] :replace (ch/framed [1 2 3]))
add2 (ch/layer :add2 [0 2] :offset (ch/framed [1 1]))
add3 (ch/layer :add3 [0 2] :offset (ch/framed [1 1 1]))]
(is (seq (ch/problems (corrected base put3 add2)))
"validation rejects a stack that would throw while reading")
(is (empty? (ch/problems (corrected base put3 add3)))
"a covering replacement establishes the shape seen by later layers")
(is (= [2 3 4] (ch/value-at (corrected base put3 add3) 0 nil)))))
(deftest generated-base-time-and-authored-correction-time-can-differ
(let [c (corrected (ch/keyed {0 0, 2 20} :hold)
(ch/layer :nudge [1 2] :offset (ch/framed 3)))
cursor (ch/cursor c nil)]
(is (= 3 (ch/value-at c 0 1 nil)))
(is (= 3 (ch/sample! cursor 0 1)))
(is (= 23 (ch/value-at c 2 1 nil)))
(is (= 20 (ch/sample! cursor 2 0)))))
(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.

View file

@ -51,7 +51,8 @@
visible (ch/keyed {0 true 20 true 21 false} :hold)
dense {:animated? true :interp :hold
:dense {:store "sizes" :offset 0 :stride 1 :frames 30}
:pose-sampled? true}
:pose-sampled? true
:over [(ch/layer :nudge [7 8] :offset (ch/framed 100))]}
shape (fn [id z group]
{:id id :kind :rect :parent :root :z z :pose-group group
:channels {[:xform :pos] (ch/keyed {0 [0 0] 8 [8 0]} :hold)
@ -86,18 +87,18 @@
low-at (fn [f] (into {} (map (fn [op] [(:node op) op])) (low-resolve f)))]
(is (empty? (clip/problems document)))
(is (= document (leaf/clip "stage" (leaf/leaves "stage" document))))
(is (= 2 (:size (get (at 7) [:first :mouth]))) "eight static frames")
(is (= 102 (:size (get (at 7) [:first :mouth]))) "eight static frames, plus its correction")
(is (= 22 (:size (get (at 8) [:first :mouth]))) "cut to source pose 20")
(is (= 6 (:size (get (at 8) [:first :mouth-detail])))
"one node may depart from its shared mouth group")
(is (= 10 (:size (get (at 8) [:second :mouth]))) "other instance chooses pose 8")
(is (= 6 (:size (get (at 7) [:first :eye]))) "eye has its own timing")
(is (= 106 (:size (get (at 7) [:first :eye]))) "eye has its own timing")
(is (= 8 (:cx (get (at 8) [:first :eye]))) "authored position still reads stage time")
(is (nil? (get (at 9) [:first :mouth]))
"generated visibility is read from the same selected pose")
(is (some? (get (at 9) [:second :mouth])))
(is (= 5 (:size (get (low-at 7) [:first :brow])))
"picture rate samples only generated motion")
(is (= 105 (:size (get (low-at 7) [:first :brow])))
"picture rate samples the generated base and leaves correction time alone")
(is (= 22 (:size (get (low-at 8) [:first :mouth])))
"an explicit cut occurs at its exact local frame, even off the picture grid")
(is (= 8 (:cx (get (low-at 8) [:first :eye])))

View file

@ -581,6 +581,18 @@
"the insert kept its own frames, so frame 9 shows what it showed")
(is (empty? (clip/problems after)))))
(deftest overwrite-clears-one-frame-and-does-not-ripple-what-follows
(let [r (lane/overwrite-drawing (document) :main :girl :n :drawing-n 5
{:extent :keep :remainder-id :right})
after (:clip r)
nodes (get-in after [:symbols :main :nodes])]
(is (= :n (:selection r)))
(is (= [[0 4] [4 5] [5 6] [6 8] [8 12]]
(mapv #(node/placed-span (get nodes %)) [:a :b :n :right :insert])))
(is (= :drawing-b (node/source (:right nodes))))
(is (= 12 (get-in after [:symbols :main :frames])))
(is (empty? (clip/problems after)))))
(deftest blank-refuses-what-it-cannot-do-in-one-piece
(let [doc (document)]
(is (re-find #"free ID" (:refused (lane/blank doc :main :girl [5 7] {})))

View file

@ -19,6 +19,31 @@
(is (= [0 6 12] (:keys lane)))
(is (= 1 (count (filter :cels (timeline/rows doc :main #{[:girl]})))))))
(deftest the-cel-sheet-is-the-same-cels-with-the-axes-turned
(let [doc (fixture/document)
column (first (timeline/cel-sheet doc :main 12))
cells (:cells column)]
(is (= :girl (:id column)))
(is (= [:a :b :insert] (mapv #(get-in cells [% :cel :id]) [0 4 8])))
(is (= [[:node :main :a [:a]]
[:node :main :b [:b]]
[:node :main :insert [:insert]]]
(mapv #(get-in cells [% :cel :select]) [0 4 8])))
(is (= (mapv :select (:cels (first (filter :cels (timeline/rows doc :main #{})))))
(mapv #(get-in cells [% :cel :select]) [0 4 8])))))
(deftest a-nested-selection-converts-the-open-playhead-to-its-owning-symbol
(let [doc (assoc-in (fixture/document) [:symbols :outer]
{:id :outer :frames 30
:nodes {:take {:id :take :kind :instance :z "a"
:time {:at 10 :rate 1} :span [0 12]
:source {:symbol :main}
:playback {:in 0 :speed 1 :end :stop}}}})]
(is (= 2 (ui/selection-frame doc nil :outer
[:node :main :a [:take :a]] 12)))
(is (= 12 (ui/selection-frame doc nil :main
[:node :main :a [:a]] 12)))))
(deftest sequence-commands-use-isolated-history-transactions
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "sequence-test")

View file

@ -197,8 +197,25 @@ try {
'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);
// The same cels with the axes turned. Selecting a sheet cell feeds the same
// action strip and therefore the same domain command and undo transaction.
await click('cel sheet');
assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 1,
'one lane is one cel-sheet column');
assert.equal(await evaluate('document.querySelectorAll(".cs-cell").length'), 20,
'one cell per authored frame');
await evaluate(`document.querySelector('.cs-cell').click()`);
await sleep(180);
await click('hold +');
s = await shot();
assert.deepEqual(placed(s), [[0, 3], [3, 4], [6, 7], [7, 8], [8, 9]],
'a command selected in the sheet has the timeline command semantics');
assert.equal(s.history.done.length, before.history.done.length + 9);
await click('timeline');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 5);
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, insert, trim, move and blank; no server writes');
console.log('PASS: lane commands agree from timeline and cel sheet; no server writes');
} finally {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));