diff --git a/docs/lane-handoff.md b/docs/lane-handoff.md index 72d3a1a..0357e74 100644 --- a/docs/lane-handoff.md +++ b/docs/lane-handoff.md @@ -1,14 +1,14 @@ # Lane and cel handoff -Status (2026-09-30): the lane model is implemented through its commands. Cels are -ordinary nodes with their own playback clock, a lane draws them as one row, the -cel commands all exist, and correction layers evaluate and survive regeneration. -What is missing is a second view and the commands that make a correction. +Status (2026-09-30): the lane model is implemented through its commands and its +first two views. Cels are ordinary nodes with their own playback clock, the +timeline draws them as one row, the cel sheet draws frames down and lanes across, +and both views issue the same commands. Correction layers evaluate and survive +regeneration. What is missing is the commands that make a correction. -Seven commits on branch `lanes`, off `624242b`. `master` is untouched and can be -fast-forwarded. Each commit message is the argument for its change and is worth -reading before touching what it did — they are the design record, more than this -file is. +The commits beginning at `3d3c1bb` are the argument for the model and are worth +reading before touching what they did — they are the design record, more than +this file is. 3d3c1bb An occurrence is a node, with a clock of its own 9446829 Reuse, duplicate and make unique: deciding what is shared @@ -65,10 +65,9 @@ decision, not a cleanup. and never shrinks it. Blanking the end of a shot leaves empty frames at the end, because deriving the window from the extent would make deleting the last drawing silently shorten the film. `lane/finish`. -- **Placing ripples; overwrite is `blank` then `place`.** Overwrite is not a - policy argument and should not become one until there is a reason `blank` - cannot serve. An argument whose second value is unimplemented is worse than - no argument. +- **Placing ripples; overwrite is `blank` then non-rippling placement.** + `lane/overwrite-drawing` composes those pieces as one transaction. Insertion + retains its ripple rule; overwrite does not move any surviving cel. - **A position inside a cel refuses and names `split`.** One command must not quietly perform two. The UI offers the retry. - **A correction has no time space of its own.** Its `:support` and its values' @@ -88,27 +87,19 @@ decision, not a cleanup. ## Next steps, in order -1. **The cel sheet.** The doc's own test that the document is separate from its - presentation: frames down, a column per lane, one cell per frame naming the - drawing. It needs NO new model — `symbol/lane-cels` and the existing commands - are the whole API. The obligation is that the same command issued from the - sheet and from the timeline produces identical document changes and the same - selection. This is the recommended next piece: it is self-contained, it is - specified, and it is the first real evidence the model is not shaped by the - timeline that grew up with it. -2. **The commands that make a correction** — Constant adjustment, Ramp, Return +1. **The commands that make a correction** — Constant adjustment, Ramp, Return motion over a selected range, per `lane-model.md`. The evaluator is done and has no opinion about how a range or a motion shape is chosen, which is now a view question. A panel also needs to offer `clip/conflicts` for resolution. Note the one open question: a correction needs a stable `:id` from somewhere, and cel ids come from the caller because this namespace is pure. -3. **Slip source and retime.** Both have real design questions open and the doc +2. **Slip source and retime.** Both have real design questions open and the doc says to refuse rather than approximate: retime needs a defined warp and interpolation behaviour, and is not moving keys whose numbers happen to fall inside a selection. -4. **Deleting reused content.** Reference discovery exists (`node/sources`, +3. **Deleting reused content.** Reference discovery exists (`node/sources`, `clip/places`, `clip/contains-symbol?`); the policy does not. -5. **Collaboration.** `lane-model.md` is explicit that one leaf per channel does +4. **Collaboration.** `lane-model.md` is explicit that one leaf per channel does NOT solve two people editing different keys of the same channel. No conflict policy exists for that. @@ -129,9 +120,12 @@ decision, not a cleanup. - **`lane/lane-frame`** converts a symbol frame to a lane frame, or returns nil through a stepped or looping lane where there is no single answer. Nil refuses; it never snaps. -- **`channel/conflict-with`** is the single rule for "can this layer apply to - this base", used by the validator, by `conflicts`, and by the regeneration - that marks them. +- **`channel/conflict-with`** is the rule for whether one offset fits a base, + used by `conflicts` and regeneration. Validation additionally follows prior + replacement layers, so it cannot approve a stack that throws when read. +- **Generated sampling applies to the base, not the hand correction.** Picture + rate and pose selection may choose an earlier generated frame; correction + support and values still read the node's current authored frame. - **Two test patterns worth copying.** `the-cursor-agrees-with-the-specification-in-any-frame-order` holds the optimized cursor to `value-at` in forward, backward and random order — add a case to it for any new channel shape. And `drawn` in `lane_test` @@ -171,7 +165,7 @@ decision, not a cleanup. From `frontend/`: - npx shadow-cljs compile test && node out/node-tests.js # 424 tests, 5,749 assertions + npx shadow-cljs compile test && node out/node-tests.js # 429 tests, 5,767 assertions npx shadow-cljs compile app # the bundle Django serves npx shadow-cljs release app # then `compile app` again — see above diff --git a/docs/lane-model.md b/docs/lane-model.md index c00fd7d..6bdd9f3 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -1,9 +1,9 @@ # The Lane Model Revised 2026-09-30. Target design. Cel ownership, source playback, the -content and cel 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. +content and cel commands, placement anywhere in a lane, overwrite, a one-row cel +strip, a frame-down cel sheet and the correction-layer evaluator are implemented; +the commands that produce a correction and the retiming commands are not. See the status note under [Proof obligations](#proof-obligations-and-implementation-order). @@ -530,22 +530,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 cels — `lane-problems` +Overwrite is `blank` followed by non-rippling placement, composed inside one +transaction; insertion keeps its ripple rule. Still unbuilt: slip source, +retime, and deleting reused content. A lane cannot hold AUDIO cels — `lane-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 cel-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 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, insert, trim, move and blank. Rewrite tests that encode superseded +resolution. Slip source and retime are also not implemented; a refusal is the +current behavior where the model demands an explicit choice nobody has made yet. +The cel sheet is the same projected cels and selection addresses with its axes +turned: frames down and lanes across, so commands selected there and in the +timeline have identical targets. The suite stands at 429 tests and 5,767 +assertions, with `frontend/test/browser/lane.mjs` driving the editor through +create, hold, overflow, undo, reuse, make unique, duplicate, 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 diff --git a/frontend/src/arthur/db.cljs b/frontend/src/arthur/db.cljs index 2480ac4..d089502 100644 --- a/frontend/src/arthur/db.cljs +++ b/frontend/src/arthur/db.cljs @@ -160,6 +160,7 @@ :ui {:open nil :tabs [] :selection nil + :time-view :timeline :tone :skin-base :tool nil :draft [] diff --git a/frontend/src/arthur/domain/channel.cljs b/frontend/src/arthur/domain/channel.cljs index ad79a24..f0fdfbc 100644 --- a/frontend/src/arthur/domain/channel.cljs +++ b/frontend/src/arthur/domain/channel.cljs @@ -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))) diff --git a/frontend/src/arthur/domain/lane.cljs b/frontend/src/arthur/domain/lane.cljs index a60b3e8..d2ad7b4 100644 --- a/frontend/src/arthur/domain/lane.cljs +++ b/frontend/src/arthur/domain/lane.cljs @@ -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 diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index 942182f..7737a28 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -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)) diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 8667184..37e45bb 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -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 diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index c5c1ed8..4b97d3d 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -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]))) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 34513f4..a589d5d 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -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])) diff --git a/frontend/test/arthur/domain/channel_test.cljs b/frontend/test/arthur/domain/channel_test.cljs index 09a393e..854ec64 100644 --- a/frontend/test/arthur/domain/channel_test.cljs +++ b/frontend/test/arthur/domain/channel_test.cljs @@ -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. diff --git a/frontend/test/arthur/domain/instance_test.cljs b/frontend/test/arthur/domain/instance_test.cljs index ee44659..a5414a3 100644 --- a/frontend/test/arthur/domain/instance_test.cljs +++ b/frontend/test/arthur/domain/instance_test.cljs @@ -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]))) diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs index 9f02c96..eb8d2d1 100644 --- a/frontend/test/arthur/domain/lane_test.cljs +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -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] {}))) diff --git a/frontend/test/arthur/events/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs index eec8bd2..8cc3a63 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -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") diff --git a/frontend/test/browser/lane.mjs b/frontend/test/browser/lane.mjs index e96be90..5b08e68 100644 --- a/frontend/test/browser/lane.mjs +++ b/frontend/test/browser/lane.mjs @@ -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' })); diff --git a/static/arthur/app.css b/static/arthur/app.css index 78bba0b..b044afe 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -772,6 +772,50 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-empty { padding: 9px; color: var(--dim); } +/* The second temporal view. Its cells carry the same selection addresses as + the cel blocks above; only the axes change. */ +.cel-sheet { + flex: 1; + min-height: 0; + overflow: auto; + display: grid; + align-content: start; + background: var(--line); + gap: 1px; +} + +.cs-head, +.cs-frame, +.cs-cell { + min-width: 0; + height: 24px; + border: 0; + border-radius: 0; + padding: 0 6px; + background: #fff; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cs-head { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + background: var(--chrome); + font-weight: 600; +} + +.cs-head.cs-frame { z-index: 3; } +.cs-frame { position: sticky; left: 0; z-index: 1; color: var(--dim); text-align: right; } +.cs-frame.on, .cs-cell.current { box-shadow: inset 3px 0 0 var(--playhead); } +.cs-cell { text-align: left; cursor: pointer; } +.cs-cell:hover { background: var(--sel-bg); } +.cs-cell.selected { background: var(--sel-bg); color: var(--sel); font-weight: 600; } + /* -------------------------------------------------------------------------- the video -> symbol dialog */