diff --git a/docs/lane-model.md b/docs/lane-model.md index 8445434..8fc978a 100644 --- a/docs/lane-model.md +++ b/docs/lane-model.md @@ -1,9 +1,10 @@ # The Lane Model Revised 2026-09-30. Target design. Occurrence ownership, source playback, the -first exposure commands and a one-row cel strip are implemented; correction -layers, the other commands and the remaining views are not. See the status note -under [Proof obligations](#proof-obligations-and-implementation-order). +content and exposure commands and a one-row cel strip are implemented; +correction layers, the range and retiming commands, and the remaining views are +not. See the status note under +[Proof obligations](#proof-obligations-and-implementation-order). This revises the Claude artifact [The Lane Model](https://claude.ai/code/artifact/cd42981d-ed08-493f-94df-b7dd6657f0e6). Its prose and diagram source were recovered from session @@ -439,18 +440,25 @@ drawing remain distinct events. Deduplicating identical ghosts is a display opti The source-channel prototype has been removed: an occurrence names one symbol and carries its own playback clock, and `node/problems` rejects the old -`[:source]` channel. `arthur.domain.sequence` holds lane validation and the -first three commands — add lane, append drawing, extend hold with explicit -ripple and shot-length policy — each one history step. The timeline draws a -lane's occurrences as cel blocks on the lane's own row. +`[:source]` channel. What a lane IS lives in `arthur.domain.symbol` beside the +other rules about a node map; `arthur.domain.sequence` holds the commands over +one — add lane, append drawing, reuse drawing, duplicate drawing, make unique, +and extend hold with explicit ripple and shot-length policy. Each is one history +step, and each refuses rather than half-applying. The timeline draws a lane's +occurrences as cel blocks on the lane's own row, and offers Make unique only +where the selected exposure actually shares its drawing. -Correction layers, the remaining commands (reuse, duplicate, make unique, blank, -split, trim, slip, retime) and the exposure-sheet view are not implemented; a -refusal is the current behavior where the model demands an explicit choice -nobody has made yet. The suite stands at 392 tests and 5,525 assertions, with +Content copies are shallow by default and keep their references to other +symbols; `:deep? true` is the explicit copy that shares nothing, so the promise +of independence is only made where it is kept. + +Correction layers, the range and retiming commands (blank, split, trim, move, +slip source, retime) and the exposure-sheet view are not implemented; a refusal +is the current behavior where the model demands an explicit choice nobody has +made yet. The suite stands at 397 tests and 5,561 assertions, with `frontend/test/browser/sequence.mjs` driving the editor through create, hold, -overflow and undo. Rewrite tests that encode superseded behavior rather than -preserving behavior to keep them green. +overflow, undo, reuse, make unique and duplicate. Rewrite tests that encode +superseded behavior rather than preserving behavior to keep them green. Build small adversarial documents and test their domain operations before expanding the interface: diff --git a/frontend/src/arthur/domain/sequence.cljs b/frontend/src/arthur/domain/sequence.cljs index 3cf8cc0..238db40 100644 --- a/frontend/src/arthur/domain/sequence.cljs +++ b/frontend/src/arthur/domain/sequence.cljs @@ -1,32 +1,26 @@ (ns arthur.domain.sequence - "Sequence groups arrange ordinary occurrences. Intervals and property clocks - have one owner; timeline rows and exposure sheets are projections of them." - (:require [arthur.domain.node :as node])) + "The commands over a lane of occurrences: make one, put drawings in it, change + how long they are exposed, and decide which of them share content. -(defn members [nodes lane] - (->> (vals nodes) - (filter #(= lane (:parent %))) - (sort-by (juxt #(or (first (node/placed-span %)) 0) #(str (:id %)))) - vec)) + WHAT A LANE IS lives in `arthur.domain.symbol`, beside the other rules about a + node map: a group with `:layout :sequence`, whose children are non-overlapping + visual occurrences. This namespace only changes them. -(defn problems [nodes] - (vec - (mapcat - (fn [[id lane]] - (when (node/sequence? lane) - (let [children (filter #(= id (:parent %)) (vals nodes)) - valid? (fn [n] - (and (= :instance (:kind n)) - (empty? (node/problems n)) - (:span n) - (every? node/finite-number? (node/placed-span n)))) - intervals (sort-by first (map node/placed-span (filter valid? children)))] - (concat - (for [n children :when (not (valid? n))] - (str "sequence " id " needs finite visual occurrences: " (:id n))) - (when (some (fn [[[a b] [c d]]] (> b c)) (partition 2 1 intervals)) - [(str "sequence " id " has overlapping occurrences")]))))) - nodes))) + EVERY COMMAND IS ONE STEP AND ALL OF IT. Each returns `{:clip :selection}` or + `{:refused reason}` — never a half-applied edit, and never a document that + `clip/problems` would reject. A command that cannot say what the person meant + refuses and says why, rather than picking for them: the overflow policy is a + caller's `:extent`, and decoupling shared content is its own command instead + of something an ordinary edit does silently. + + IDS FOR OCCURRENCES COME FROM THE CALLER, because an occurrence's identity is + a uuid and this namespace is pure. Ids for new CONTENT are derived from the + drawing being copied — `clip/free-id` is pure too, and `drawing-a-2` says what + it came from in a way `symbol-7` does not." + (:require [arthur.domain.bring :as bring] + [arthur.domain.clip :as clip] + [arthur.domain.node :as node] + [arthur.domain.symbol :as symbol])) (defn- lane-map "Lane -> containing symbol, as an invertible map in the opposite direction. @@ -43,14 +37,14 @@ (defn- finish [clip sid nodes selection extent] - (let [sym (get-in clip [:symbols sid]) + (let [sym (clip/symbol clip sid) changed (for [[id n] nodes :when (node/sequence? n) - child (members nodes id) + child (symbol/sequence-members nodes id) :let [m (lane-map nodes id) end (second (node/placed-span child))]] (when m (+ (:at m) (/ end (:rate m))))) end (apply max (:frames sym) (keep identity changed)) - ps (problems nodes)] + ps (symbol/problems (assoc sym :nodes nodes))] (cond (seq ps) {:refused (first ps)} (not (#{:keep :grow-symbol} extent)) {:refused "choose an explicit shot-length policy"} @@ -72,10 +66,14 @@ lane (get nodes (:parent n)) rate (:rate (node/time-of n)) span (:span n) - m (when lane (lane-map nodes (:id lane)))] + m (when lane (lane-map nodes (:id lane))) + ;; The LANE's own shape, not the whole symbol's: refusing an exposure + ;; edit over some unrelated defect elsewhere in the symbol would be + ;; this command answering for a part of the document it never touches. + broken (first (symbol/sequence-problems nodes))] (cond (not (node/sequence? lane)) {:refused "select an occurrence in a sequence lane"} - (seq (problems nodes)) {:refused (first (problems nodes))} + broken {:refused broken} (not (and (integer? delta) (not (zero? delta)))) {:refused "hold change must be a nonzero whole number of lane frames"} (not (zero? (:speed (node/playback-of n)))) {:refused "hold length applies to a held drawing"} (nil? m) {:refused "exposure timing through a stepped or looping lane is not supported"} @@ -83,7 +81,7 @@ :else (let [[_ boundary] (node/placed-span n) later (filter #(>= (first (node/placed-span %)) boundary) - (members nodes (:id lane))) + (symbol/sequence-members nodes (:id lane))) nodes (assoc-in nodes [id :span 1] (+ (second span) (* rate delta))) nodes (reduce (fn [ns sibling] (update-in ns [(:id sibling) :time :at] (fnil + 0) delta)) @@ -91,32 +89,138 @@ (finish clip sid nodes id extent))))) (defn add-lane [clip sid id] - (if (or (nil? (get-in clip [:symbols 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"} {:clip (assoc-in clip [:symbols sid :nodes id] {:id id :name "drawings" :kind :group :layout :sequence :z (str "z-" id)}) :selection id})) -(defn append-drawing - "Append fresh one-frame content and a held occurrence. IDs come from the - caller so a command is deterministic and replayable." - [clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}] +;; --------------------------------------------------------------------------- +;; putting drawings in a lane + +(defn- held + "A one-frame held occurrence of `drawing-id`, starting at lane frame `at`. + + Held rather than playing, and one frame rather than the length of what it + places: an exposure's duration is the lane's business — `extend-hold` is how + it changes — and reading it off the content would make placing a ten-frame + animation and holding its first drawing the same gesture." + [id lane-id drawing-id at] + {:id id :kind :instance :parent lane-id :z (str "a-" id) + :span [0 1] :time {:at at :rate 1} + :source {:symbol drawing-id} :playback {:in 0 :speed 0 :end :stop}}) + +(defn- append + "Put a held occurrence of `drawing-id` after everything already in `lane-id`. + `:frame` in the result is where it lands, for a caller that wants to look at + what it just made." + [clip sid lane-id id drawing-id extent] + (let [nodes (get-in clip [:symbols sid :nodes]) + at (apply max 0 (map #(second (node/placed-span %)) + (symbol/sequence-members nodes lane-id))) + result (finish clip sid (assoc nodes id (held id lane-id drawing-id at)) id extent) + m (lane-map nodes lane-id)] + (cond-> result + (:clip result) (assoc :frame (+ (:at m) (/ at (:rate m))))))) + +(defn- appendable + "Why a held occurrence cannot go into `lane-id`, or nil." + [clip sid lane-id id] (let [nodes (get-in clip [:symbols sid :nodes]) lane (get nodes lane-id)] (cond - (not (node/sequence? lane)) {:refused "select a sequence lane"} - (seq (problems nodes)) {:refused (first (problems nodes))} - (or (contains? nodes id) (get-in clip [:symbols drawing-id])) {:refused "the new drawing IDs are already used"} - (nil? (lane-map nodes lane-id)) {:refused "drawing creation through a stepped or looping lane is not supported"} - :else - (let [at (apply max 0 (map #(second (node/placed-span %)) (members nodes lane-id))) - n {:id id :kind :instance :parent lane-id :z (str "a-" id) - :span [0 1] :time {:at at :rate 1} - :source {:symbol drawing-id} :playback {:in 0 :speed 0 :end :stop}} - clip (assoc-in clip [:symbols drawing-id] - {:id drawing-id :name (name drawing-id) :frames 1 :nodes {}})] - (let [result (finish clip sid (assoc nodes id n) id extent) - m (lane-map nodes lane-id)] - (cond-> result - (:clip result) (assoc :frame (+ (:at m) (/ at (:rate m)))))))))) + (not (node/sequence? lane)) "select a sequence lane" + (contains? nodes id) "the new occurrence ID is already used" + (nil? (lane-map nodes lane-id)) "drawing creation through a stepped or looping lane is not supported" + :else (first (symbol/sequence-problems nodes))))) + +(defn append-drawing + "Append fresh empty content and a held occurrence of it. IDs come from the + caller so a command is deterministic and replayable. + + 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 + from a gap." + [clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}] + (if-let [why (or (appendable clip sid lane-id id) + (when (clip/symbol clip drawing-id) "the new drawing ID is already used"))] + {:refused why} + (append (assoc-in clip [:symbols drawing-id] + {:id drawing-id :name (name drawing-id) :frames 1 :nodes {}}) + sid lane-id id drawing-id extent))) + +(defn reuse-drawing + "Append a held occurrence of content the document ALREADY has, so the same + drawing is exposed twice and editing it changes both exposures. + + 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 + discover later when an edit turns up somewhere else." + [clip sid lane-id id drawing-id {:keys [extent] :or {extent :keep}}] + (if-let [why (or (appendable clip sid lane-id id) + (when-not (clip/symbol clip drawing-id) "there is no such drawing to reuse") + ;; Placing something that contains this symbol would close a + ;; loop, and a lane is no different from any other placement. + (when (clip/contains-symbol? clip drawing-id sid) + "a symbol cannot go inside itself"))] + {:refused why} + (append clip sid lane-id id drawing-id extent))) + +(defn- copied + "A copy of symbol `from`, as `{:clip :id}`. + + SHALLOW by default: its own nodes and channels are copied, and its references + to other symbols are kept, so a head built out of reusable eyes still uses + those eyes. `deep?` copies everything it places as well, with new ids + throughout, for a drawing that must share nothing — the distinction the + shallow copy cannot make on its own, and a promise of independence that only + the deep one keeps." + [clip from deep?] + (if deep? + (let [{c :clip ids :ids} (bring/symbols clip clip [from] {})] + {:clip c :id (ids from)}) + (let [id (clip/free-id (:symbols clip) from)] + {:clip (assoc-in clip [:symbols id] (assoc (clip/symbol clip from) :id id)) + :id id}))) + +(defn duplicate-drawing + "Append a held occurrence of a COPY of what occurrence `id` places, for when + the drawing on screen is the starting point for the next one. + + The copy is of the content only. The new exposure is a plain one-frame hold + 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 + duplicate the treatment of one use of it." + [clip sid id new-id {:keys [extent deep?] :or {extent :keep}}] + (let [n (get-in clip [:symbols sid :nodes id]) + from (node/source n)] + (if-let [why (or (when-not from "select an occurrence to duplicate") + (when-not (clip/symbol clip from) "the drawing it places is missing") + (appendable clip sid (:parent n) new-id))] + {:refused why} + (let [{c :clip copy :id} (copied clip from deep?)] + (append c sid (:parent n) new-id copy extent))))) + +(defn make-unique + "Point occurrence `id` at a private copy of its content, leaving every other + occurrence of that drawing sharing the original. + + Refused when nothing else uses it: a drawing with one exposure is already + unique, and answering with a silent copy would leave a second identical symbol + in the library for no reason a person could see." + [clip sid id {:keys [deep?]}] + (let [n (get-in clip [:symbols sid :nodes id]) + from (node/source n) + elsewhere (for [[osid osym] (:symbols clip) + [oid on] (:nodes osym) + :when (and (= from (node/source on)) (not= [sid id] [osid oid]))] + [osid oid])] + (if-let [why (or (when-not from "select an occurrence to make unique") + (when-not (clip/symbol clip from) "the drawing it places is missing") + (when (empty? elsewhere) "nothing else uses this drawing"))] + {:refused why} + (let [{c :clip copy :id} (copied clip from deep?) + c (assoc-in c [:symbols sid :nodes id :source :symbol] copy) + ps (clip/problems c)] + (if (seq ps) {:refused (first ps)} {:clip c :selection id}))))) diff --git a/frontend/src/arthur/domain/symbol.cljs b/frontend/src/arthur/domain/symbol.cljs index 51102d1..6633605 100644 --- a/frontend/src/arthur/domain/symbol.cljs +++ b/frontend/src/arthur/domain/symbol.cljs @@ -55,7 +55,6 @@ fill in the same channel rather than convert into a second format." (:require [arthur.domain.channel :as ch] [arthur.domain.node :as node] - [arthur.domain.sequence :as sequence] [arthur.domain.pose :as pose] [arthur.domain.trace :as trace] [arthur.domain.palette :as pal])) @@ -91,6 +90,47 @@ [nodes id] (dec (count (lineage nodes id)))) +(defn sequence-members + "The occurrences of lane `lane`, in the order they are exposed. + + Sorted by where they START, not by `:z`: a lane's blocks follow one another in + time, and two of them cannot be in the same place for `:z` to decide between. + Ties go to the id so the order is the same on every run." + [nodes lane] + (->> (vals nodes) + (filter #(= lane (:parent %))) + (sort-by (juxt #(or (first (node/placed-span %)) 0) #(str (:id %)))) + vec)) + +(defn sequence-problems + "What makes a lane not a lane. A SEQUENCE is the one composition rule the node + map carries — ordinary groups compose freely — so it is checked here, beside + the parent and stencil references, rather than wherever a command happens to + build one. + + Occurrences must be visual, finite and non-overlapping. An accidental overlap + is refused rather than resolved by draw order: two drawings exposed on one + frame of one lane is a document nobody meant to write, and picking a winner + would hide it. Empty lanes are valid — a lane is made before it is filled." + [nodes] + (vec + (mapcat + (fn [[id lane]] + (when (node/sequence? lane) + (let [children (filter #(= id (:parent %)) (vals nodes)) + valid? (fn [n] + (and (= :instance (:kind n)) + (empty? (node/problems n)) + (:span n) + (every? node/finite-number? (node/placed-span n)))) + intervals (sort-by first (map node/placed-span (filter valid? children)))] + (concat + (for [n children :when (not (valid? n))] + (str "sequence " id " needs finite visual occurrences: " (:id n))) + (when (some (fn [[[_ b] [c _]]] (> b c)) (partition 2 1 intervals)) + [(str "sequence " id " has overlapping occurrences")]))))) + nodes))) + (defn order "Node ids in topological order: every node after its parent. @@ -573,7 +613,7 @@ (if-not (map? nodes) [":nodes must be a map of id -> node"] (-> [] - (into (sequence/problems nodes)) + (into (sequence-problems nodes)) (into (for [[id n] nodes :when (not= id (:id n))] (str "node under key " (pr-str id) " has :id " (pr-str (:id n))))) diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index d6a7e38..e2360e3 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -58,21 +58,64 @@ sid (get-in db [:ui :open])] (apply-sequence-command db sid (sequence/add-lane clip sid (random-uuid)) nil)))) +(defn- committed + "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 + everything already in the lane, which is usually off the playhead, and a + drawing you cannot see is not one you can draw in." + [db sid result retry] + (let [{clip :clip st :store} (store/entry (:clip/current db)) + path (nth (get-in db [:ui :selection]) 3 nil) + {:keys [at rate]} (:time (nest/inside clip st (get-in db [:ui :open]) + (if (seq path) (pop path) []) + (get-in db [:playback :frame])))] + (cond-> {:db (apply-sequence-command db sid result retry)} + (and (:clip result) (:frame result) rate) + (assoc :dispatch [::playback/seek (+ at (/ (:frame result) rate))])))) + +(defn- selected-lane + "The lane a command should act in: the selected lane itself, or the one + holding the selected occurrence." + [clip sid id] + (let [n (get-in clip [:symbols sid :nodes id])] + (if (node/sequence? n) id (:parent n)))) + (rf/reg-event-fx ::append-drawing (fn [{:keys [db]} [_ extent]] - (let [{clip :clip st :store} (store/entry (:clip/current db)) - [_ sid id path] (get-in db [:ui :selection]) - n (get-in clip [:symbols sid :nodes id]) - lane (if (node/sequence? n) id (:parent n)) - result (sequence/append-drawing clip sid lane (random-uuid) (clip/fresh-id clip) - {:extent (or extent :keep)}) - context (nest/inside clip st (get-in db [:ui :open]) (if (seq path) (pop path) []) - (get-in db [:playback :frame])) - {:keys [at rate]} (:time context)] - (cond-> {:db (apply-sequence-command db sid result [::append-drawing :grow-symbol])} - (and (:clip result) rate) - (assoc :dispatch [::playback/seek (+ at (/ (:frame result) rate))]))))) + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + result (sequence/append-drawing clip sid (selected-lane clip sid id) + (random-uuid) (clip/fresh-id clip) + {:extent (or extent :keep)})] + (committed db sid result [::append-drawing :grow-symbol])))) + +(rf/reg-event-fx + ::reuse-drawing + (fn [{:keys [db]} [_ extent]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + result (sequence/reuse-drawing clip sid (selected-lane clip sid id) (random-uuid) + (node/source (get-in clip [:symbols sid :nodes id])) + {:extent (or extent :keep)})] + (committed db sid result [::reuse-drawing :grow-symbol])))) + +(rf/reg-event-fx + ::duplicate-drawing + (fn [{:keys [db]} [_ extent deep?]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection]) + result (sequence/duplicate-drawing clip sid id (random-uuid) + {:extent (or extent :keep) :deep? deep?})] + (committed db sid result [::duplicate-drawing :grow-symbol deep?])))) + +(rf/reg-event-db + ::make-unique + (fn [db [_ deep?]] + (let [clip (:clip (store/entry (:clip/current db))) + [_ sid id] (get-in db [:ui :selection])] + (apply-sequence-command db sid (sequence/make-unique clip sid id {:deep? deep?}) nil)))) (rf/reg-event-db ::extend-hold diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 5de5c40..bf34ecb 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -23,7 +23,6 @@ (:require [clojure.string :as str] [arthur.domain.node :as node] [arthur.domain.nest :as nest] - [arthur.domain.sequence :as sequence] [arthur.domain.symbol :as symbol] [arthur.domain.trace :as trace] [arthur.events.playback :as pb] @@ -158,7 +157,7 @@ :source (node/source child) :span (mapv self (node/placed-span child)) :select [:node sid (:id child) (conj path (:id child))]}) - (sequence/members (:nodes sym) id))))] + (symbol/sequence-members (:nodes sym) id))))] (if-not open? [row] (-> [row] @@ -230,7 +229,14 @@ n (get-in clip [:symbols sid :nodes id]) lane (if (node/sequence? n) n (get-in clip [:symbols sid :nodes (:parent n)])) lane? (node/sequence? lane) - held? (and lane? (= :instance (:kind n)) (zero? (:speed (node/playback-of n))))] + cel? (and lane? (= :instance (:kind n)) (some? (node/source n))) + held? (and cel? (zero? (:speed (node/playback-of n)))) + ;; Shared use is shown rather than discovered: the button that decouples + ;; an exposure is only offered where there is something to decouple from. + shared? (and cel? (< 1 (count (for [[_ sym] (:symbols clip) + [_ other] (:nodes sym) + :when (= (node/source n) (node/source other))] + other))))] [:div.pane-head [:button {:on-click #(rf/dispatch [::pb/toggle])} (if playing? "pause" "play")] [:button {:on-click #(rf/dispatch [::pb/seek 0])} "|<"] @@ -256,6 +262,15 @@ [:button {:disabled (not lane?) :title "append a new independent drawing to the selected lane" :on-click #(rf/dispatch [::ui/append-drawing])} "new drawing"] + [:button {:disabled (not cel?) + :title "expose this same drawing again — one drawing, two exposures" + :on-click #(rf/dispatch [::ui/reuse-drawing])} "reuse"] + [:button {:disabled (not cel?) + :title "append a copy of this drawing, to draw the next one over it" + :on-click #(rf/dispatch [::ui/duplicate-drawing])} "duplicate"] + [:button {:disabled (not shared?) + :title "give this exposure its own copy; other exposures keep sharing" + :on-click #(rf/dispatch [::ui/make-unique])} "make unique"] [:button {:disabled (not held?) :title "shorten this exposure; ripple later drawings, keeping lane keys fixed" :on-click #(rf/dispatch [::ui/extend-hold -1])} "hold −"] diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs index 1c739f8..bd585b1 100644 --- a/frontend/test/arthur/domain/lane_test.cljs +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -236,3 +236,101 @@ (is (nil? (:clip result))) (is (= 15 (get-in (sequence/extend-hold doc :main :a 2 {:extent :grow-symbol}) [:clip :symbols :main :frames]))))) + +(deftest reuse-shares-content-and-make-unique-decouples-one-exposure + (let [doc (document) + shared (:clip (sequence/reuse-drawing doc :main :girl :c :drawing-a + {:extent :grow-symbol})) + edit (fn [c sym x] + (assoc-in c [:symbols sym :nodes :mark :channels [:xform :pos]] + (ch/framed [x 0])))] + (is (:refused (sequence/reuse-drawing doc :main :girl :c :drawing-a {})) + "the shot has to be extended on purpose") + (is (= :drawing-a (node/source (get-in shared [:symbols :main :nodes :c])))) + (is (= [12 13] (node/placed-span (get-in shared [:symbols :main :nodes :c])))) + (is (empty? (clip/problems shared))) + ;; One drawing, two exposures: the edit arrives at both. + (let [at (sample (edit shared :drawing-a 99) [0 12])] + (is (= 99 (get-in at [0 [:a :mark]]))) + (is (= 99 (get-in at [12 [:c :mark]])))) + (let [unique (:clip (sequence/make-unique shared :main :c {}))] + (is (= :drawing-a-2 (node/source (get-in unique [:symbols :main :nodes :c])))) + (is (= (:nodes (get-in shared [:symbols :drawing-a])) + (:nodes (get-in unique [:symbols :drawing-a-2]))) + "a copy of the same drawing, not an empty one") + (is (= :drawing-a (node/source (get-in unique [:symbols :main :nodes :a]))) + "the other exposure keeps the original") + (let [at (sample (edit unique :drawing-a 99) [0 12])] + (is (= 99 (get-in at [0 [:a :mark]]))) + (is (= 10 (get-in at [12 [:c :mark]])) "the exposure made unique is untouched")) + (let [at (sample (edit unique :drawing-a-2 99) [0 12])] + (is (= 10 (get-in at [0 [:a :mark]])) "and does not reach back")) + (is (empty? (clip/problems unique)))) + ;; Nothing else places drawing-b, so there is nothing to decouple from. + (is (:refused (sequence/make-unique doc :main :b {}))) + (is (:refused (sequence/make-unique doc :main :girl {})) + "a lane places nothing itself"))) + +(deftest duplicate-copies-the-drawing-and-not-the-exposure + (let [doc (document) + made (:clip (sequence/duplicate-drawing doc :main :b :d {:extent :grow-symbol})) + n (get-in made [:symbols :main :nodes :d])] + (is (= :drawing-b-2 (node/source n))) + (is (= (:nodes (get-in doc [:symbols :drawing-b])) + (:nodes (get-in made [:symbols :drawing-b-2])))) + (is (= [12 13] (node/placed-span n))) + (is (= {:in 0 :speed 0 :end :stop} (:playback n))) + (is (nil? (:channels n)) "B's own position correction belongs to B's exposure") + (is (= (get-in doc [:symbols :main :nodes :b]) + (get-in made [:symbols :main :nodes :b])) + "the drawing duplicated is left as it was") + (is (empty? (clip/problems made))))) + +(deftest a-shallow-copy-keeps-its-parts-and-a-deep-copy-owns-them + ;; A drawing assembled from another symbol: copying it shallowly must keep + ;; using that part, and only an explicit deep copy may promise independence. + (let [doc (assoc-in (document) [:symbols :drawing-a :nodes :part] + {:id :part :kind :instance :z "b" :span [0 1] + :time {:at 0 :rate 1} :source {:symbol :wave} + :playback {:in 0 :speed 0 :end :stop}}) + copy (fn [opts] (:clip (sequence/duplicate-drawing + doc :main :a :d (merge {:extent :grow-symbol} opts)))) + shallow (copy {}) + deep (copy {:deep? true})] + (is (= :wave (node/source (get-in shallow [:symbols :drawing-a-2 :nodes :part])))) + (is (nil? (get-in shallow [:symbols :wave-2]))) + (is (= :wave-2 (node/source (get-in deep [:symbols :drawing-a-2 :nodes :part])))) + (is (= (:nodes (get-in doc [:symbols :wave])) (:nodes (get-in deep [:symbols :wave-2])))) + (is (empty? (clip/problems shallow))) + (is (empty? (clip/problems deep))))) + +(deftest reuse-refuses-what-would-not-be-a-document + (let [doc (document)] + (is (:refused (sequence/reuse-drawing doc :main :girl :c :nothing-here {}))) + (is (:refused (sequence/reuse-drawing doc :main :girl :c :main {:extent :grow-symbol})) + "a symbol cannot go inside itself") + (is (:refused (sequence/reuse-drawing doc :main :girl :a :drawing-a {:extent :grow-symbol})) + "an occurrence ID in use is not free") + (is (:refused (sequence/reuse-drawing doc :main :plate :c :drawing-a {}))) + (is (:refused (sequence/duplicate-drawing doc :main :girl :d {}))))) + +(deftest drawing-on-twos-does-not-quantize-the-lane-transform + ;; Exposure length IS the drawing cadence, and it is the only thing on twos + ;; here: the lane's transform has its own clock and keeps moving every frame. + ;; Stepping it would be the cel cadence leaking into continuous motion. + (let [cel (fn [id source at] (occurrence id source at 2 0)) + doc (-> (document) + (update-in [:symbols :main :nodes] dissoc :a :b :insert) + (update-in [:symbols :main :nodes] merge + {:c0 (cel :c0 :drawing-a 0) + :c1 (cel :c1 :drawing-b 2) + :c2 (cel :c2 :drawing-a 4)})) + xs {:c0 10 :c1 20 :c2 10} + at (sample doc (range 6)) + showing (fn [f] (first (dissoc (at f) :plate)))] + (is (empty? (clip/problems doc))) + (is (= [:c0 :c0 :c1 :c1 :c2 :c2] (mapv #(first (key (showing %))) (range 6))) + "the drawing showing changes every second frame") + (is (= [0 10 20 30 40 50] + (mapv (fn [f] (let [[[id _] cx] (showing f)] (- cx (xs id)))) (range 6))) + "and the lane moves on every frame, odd ones included"))) diff --git a/frontend/test/browser/sequence.mjs b/frontend/test/browser/sequence.mjs index 32693ae..06203ed 100644 --- a/frontend/test/browser/sequence.mjs +++ b/frontend/test/browser/sequence.mjs @@ -113,8 +113,44 @@ try { await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key:'z', ctrlKey:true, bubbles:true}))`); await sleep(250); assert.deepEqual((await shot()).clip, before.clip, 'one undo restores exposure, ripple, and shot length'); + + // Sharing: one drawing exposed twice, then one exposure decoupled. Room is + // made first so these assertions are about content and not about overflow. + await evaluate(`(() => { + const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db); + arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')), + clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 20)); + document.querySelector('.tl-cel').click(); + })()`); + await sleep(200); + const enabled = async label => await evaluate(`(() => { + const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(label)}); + return !!b && !b.disabled; + })()`); + assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet'); + await click('reuse'); + s = await shot(); + let cels = instances(s); + assert.equal(cels.length, 3); + assert.equal(cels[2].source.symbol, cels[0].source.symbol, 'reuse exposes the same drawing'); + assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 3); + assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1, + 'three exposures, still one row'); + assert.equal(await enabled('make unique'), true); + await click('make unique'); + s = await shot(); + cels = instances(s); + assert.notEqual(cels[2].source.symbol, cels[0].source.symbol, 'that exposure has its own drawing'); + assert.equal(await enabled('make unique'), false, 'and is not shared any more'); + await click('duplicate'); + s = await shot(); + cels = instances(s); + assert.equal(cels.length, 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'); + assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps'); assert.equal(errors.length, 0, JSON.stringify(errors)); - console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo; no server writes'); + console.log('PASS: create lane/drawings, one-row cels, hold ripple, seek, explicit overflow, atomic undo, reuse/make unique/duplicate; no server writes'); } finally { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));