Unify lane overlap and nested timing

This commit is contained in:
Your Name 2026-10-02 09:06:28 -04:00
parent 1b2b4ad3d2
commit 8f09b7b47f
6 changed files with 140 additions and 86 deletions

View file

@ -454,7 +454,7 @@
;; a boundary can be rolled depend on which of them `some` reaches ;; a boundary can be rolled depend on which of them `some` reaches
;; first. `finish` does the same `problems` check and the overlap one ;; first. `finish` does the same `problems` check and the overlap one
;; too, so this is less code and one fewer invariant to remember. ;; too, so this is less code and one fewer invariant to remember.
(span/finish clip (:sid here) moved id :grow-symbol))))) (span/claim clip (:sid here) moved id :grow-symbol (random-uuid))))))
(defn resize-out (defn resize-out
"Move the right edge of the node at `path` by `df` frames of `open`. "Move the right edge of the node at `path` by `df` frames of `open`.

View file

@ -144,6 +144,47 @@
[n which f] [n which f]
(assoc-in n [:span (case which :in 0 :out 1)] (local n f))) (assoc-in n [:span (case which :in 0 :out 1)] (local n f)))
(defn claim
"Commit proposed `nodes`, with `id` claiming its interval in lane mode.
This is the one difference between editing a lane and a composition. In a
composition it is exactly `finish`. In a lane, immediately before that same
commit, clips covered by the edited one are removed and clips crossing either
edge are trimmed. A clip crossing both edges is split and therefore needs a
caller-supplied `remainder-id`."
[clip sid nodes id extent remainder-id]
(let [n (get nodes id)
lane-child? (and (symbol/lane? (clip/symbol clip sid))
n (nil? (:parent n)) (node/placed-span n))]
(if-not lane-child?
(finish clip sid nodes id extent)
(let [[a b] (node/placed-span n)
others (remove #(= id (:id %)) (symbol/children nodes))
spanning (first (filter #(let [[lo hi] (node/placed-span %)]
(and (< lo a) (> hi b)))
others))]
(if (and spanning (or (nil? remainder-id) (contains? nodes remainder-id)))
{:refused "claiming time inside one clip needs a free ID for its remainder"}
(finish
clip sid
(reduce
(fn [ns other]
(let [oid (:id other)
[lo hi] (node/placed-span other)]
(cond
(or (<= hi a) (>= lo b)) ns
(and (< lo a) (> hi b))
(-> ns
(assoc oid (edged other :out a))
(assoc remainder-id
(assoc (edged other :in b) :id remainder-id
:z (str "a-" remainder-id))))
(and (>= lo a) (<= hi b)) (dissoc ns oid)
(< lo a) (assoc ns oid (edged other :out a))
:else (assoc ns oid (edged other :in b)))))
nodes others)
id extent))))))
(defn host-frame (defn host-frame
"Symbol frame `f` as a frame of the space node `id` is POSITIONED in — its "Symbol frame `f` as a frame of the space node `id` is POSITIONED in — its
parent's — which is the frame space every command here takes its coordinate parent's — which is the frame space every command here takes its coordinate
@ -335,7 +376,7 @@
(let [nodes (get-in clip [:symbols sid :nodes]) (let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id) {:keys [node refused]} (subject nodes id)
[lo old-out] (when node (node/placed-span node)) [lo old-out] (when node (node/placed-span node))
later (when node later (when (and node ripple?)
(filter #(>= (first (node/placed-span %)) old-out) (filter #(>= (first (node/placed-span %)) old-out)
(siblings clip sid nodes id)))] (siblings clip sid nodes id)))]
(cond (cond
@ -346,22 +387,10 @@
:else :else
(let [delta (- to old-out) (let [delta (- to old-out)
resized (assoc nodes id (edged node :out to)) resized (assoc nodes id (edged node :out to))
changed changed (reduce (fn [ns sibling]
(if ripple?
(reduce (fn [ns sibling]
(update-in ns [(:id sibling) :time :at] (fnil + 0) delta)) (update-in ns [(:id sibling) :time :at] (fnil + 0) delta))
resized later) resized later)]
(if (pos? delta) (claim clip sid changed id extent nil)))))
(reduce
(fn [ns sibling]
(let [[s e] (node/placed-span sibling)]
(cond
(>= s to) ns
(<= e to) (dissoc ns (:id sibling))
:else (assoc ns (:id sibling) (edged sibling :in to)))))
resized later)
resized))]
(finish clip sid changed id extent)))))
(defn resize-in (defn resize-in
"Put clip `id`'s left edge at parent frame `to`. Shrinking leaves a gap; "Put clip `id`'s left edge at parent frame `to`. Shrinking leaves a gap;
@ -369,29 +398,14 @@
[clip sid id to] [clip sid id to]
(let [nodes (get-in clip [:symbols sid :nodes]) (let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id) {:keys [node refused]} (subject nodes id)
[old-in hi] (when node (node/placed-span node)) [old-in hi] (when node (node/placed-span node))]
earlier (when node
(filter #(<= (second (node/placed-span %)) old-in)
(siblings clip sid nodes id)))]
(cond (cond
refused {:refused refused} refused {:refused refused}
(not (integer? to)) {:refused "an edge goes to a whole frame"} (not (integer? to)) {:refused "an edge goes to a whole frame"}
(not (< to hi)) {:refused "a clip must keep at least one frame"} (not (< to hi)) {:refused "a clip must keep at least one frame"}
(neg? to) {:refused "a clip cannot begin before the shot"} (neg? to) {:refused "a clip cannot begin before the shot"}
(= to old-in) {:clip clip :selection id} (= to old-in) {:clip clip :selection id}
:else :else (claim clip sid (assoc nodes id (edged node :in to)) id :keep nil))))
(let [resized (assoc nodes id (edged node :in to))
changed (if (< to old-in)
(reduce
(fn [ns sibling]
(let [[s e] (node/placed-span sibling)]
(cond
(<= e to) ns
(>= s to) (dissoc ns (:id sibling))
:else (assoc ns (:id sibling) (edged sibling :out to)))))
resized earlier)
resized)]
(finish clip sid changed id :keep)))))
(defn roll (defn roll
"Move the shared boundary between adjacent clips `left-id` and `right-id`. "Move the shared boundary between adjacent clips `left-id` and `right-id`.
@ -460,17 +474,6 @@
;; selected rather than being handed a clip it did not ask for. ;; selected rather than being handed a clip it did not ask for.
(finish clip sid nodes (when spanning id) :keep))))) (finish clip sid nodes (when spanning id) :keep)))))
(defn- cleared
"`clip` with frames `[at (+ at duration))` of `sid` emptied where `sid` is drawn
as a lane, and untouched where it is not: outside lane mode a placement does not
claim time, because being on screen together is what compositing IS.
`{:clip c}` or `{:refused why}`, so one `if-let` covers both."
[clip sid at duration remainder-id]
(if-not (symbol/lane? (clip/symbol clip sid))
{:clip clip}
(blank clip sid [at (+ at duration)] {:id remainder-id})))
(defn extend-hold (defn extend-hold
"Change one held clip's duration by `delta` frames and ripple its later "Change one held clip's duration by `delta` frames and ripple its later
siblings. Keys, source clocks and the clips' own channels stay put. siblings. Keys, source clocks and the clips' own channels stay put.
@ -528,12 +531,8 @@
(and remainder-id (contains? nodes remainder-id)) (and remainder-id (contains? nodes remainder-id))
{:refused "the remainder clip needs a free ID"} {:refused "the remainder clip needs a free ID"}
:else :else
(let [room (cleared clip sid at duration remainder-id)] (let [placed (update-in n [:time :at] (fnil + 0) (- at lo))]
(if (:refused room) (claim clip sid (assoc nodes id placed) id extent remainder-id)))))
room
(let [placed (update-in n [:time :at] (fnil + 0) (- at lo))
nodes (assoc (get-in (:clip room) [:symbols sid :nodes]) id placed)]
(finish (:clip room) sid nodes id extent)))))))
(defn place-symbol (defn place-symbol
"Materialize an instance of `source-id`, then place it into `sid` at `at`. "Materialize an instance of `source-id`, then place it into `sid` at `at`.
@ -746,12 +745,11 @@
"the remainder clip needs a free ID different from the new clip" "the remainder clip needs a free ID different from the new clip"
(clip/symbol clip drawing-id) "the new drawing ID is already used")] (clip/symbol clip drawing-id) "the new drawing ID is already used")]
{:refused why} {:refused why}
(let [room (cleared clip sid at 1 remainder-id)] (let [c (assoc-in clip [:symbols drawing-id]
(if (:refused room) {:id drawing-id :name (name drawing-id)
room :fps (clip/fps clip sid) :frames 1 :nodes {}})
(place (assoc-in (:clip room) [:symbols drawing-id] nodes (assoc nodes id (held id drawing-id at))]
{:id drawing-id :name (name drawing-id) :fps (clip/fps clip sid) :frames 1 :nodes {}}) (claim c sid nodes id extent remainder-id)))))
sid id drawing-id at extent false))))))
(defn make-unique (defn make-unique
"Point clip `id` at a private copy of its content, leaving every other "Point clip `id` at a private copy of its content, leaving every other

View file

@ -696,9 +696,9 @@
::drop-clear ::drop-clear
(fn [db _] (update db :ui dissoc :drop))) (fn [db _] (update db :ui dissoc :drop)))
(defn drop-destination (defn drop-destination-at
"Which symbol a drop lands in and on which of its frames: `{:clip :sid :at "Which symbol a drop on `target` lands in and on which of its frames:
:path}`, or `{:refused why}`. `{:clip :sid :at :path}`, or `{:refused why}`.
ONE RULE AND EVERY DROP ASKS IT — a symbol from the pool, a sound, and a video ONE RULE AND EVERY DROP ASKS IT — a symbol from the pool, a sound, and a video
brought in as a take alike. The pointer names a ROW, `target`, and a row leads brought in as a take alike. The pointer names a ROW, `target`, and a row leads
@ -714,12 +714,11 @@
`:clip` is handed back unchanged and is in the result only so the callers that `:clip` is handed back unchanged and is in the result only so the callers that
used to be given a document with a freshly made lane in it go on reading one used to be given a document with a freshly made lane in it go on reading one
thing." thing."
[db document st frame target] [document st open frame target]
(let [target (if (vector? target) (let [target (if (vector? target)
(let [[_ sid id path] target] (let [[_ sid id path] target]
{:sid sid :id id :path path}) {:sid sid :id id :path path})
target) target)
open (get-in db [:ui :open])
path (cond path (cond
(nil? target) [] (nil? target) []
(= :instance (get-in document [:symbols (:sid target) (= :instance (get-in document [:symbols (:sid target)
@ -732,6 +731,11 @@
(not (integer? at)) {:refused "the drop is not on one frame of that symbol"} (not (integer? at)) {:refused "the drop is not on one frame of that symbol"}
:else {:clip document :sid sid :at at :path path}))) :else {:clip document :sid sid :at at :path path})))
(defn drop-destination
"`drop-destination-at` from the symbol currently open in `db`."
[db document st frame target]
(drop-destination-at document st (get-in db [:ui :open]) frame target))
(defn landed (defn landed
"`db` after a drop that produced `result`, with `uuid` selected." "`db` after a drop that produced `result`, with `uuid` selected."
[db {:keys [sid path]} uuid result] [db {:keys [sid path]} uuid result]

View file

@ -43,9 +43,16 @@
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; the rows ;; the rows
(defn- time->parent
"The inverse of a time map: where one of its output frames sits in its input."
[{:keys [at rate]}]
(if (and (zero? at) (= 1 rate))
identity
(fn [f] (+ at (/ f rate)))))
(defn- local->parent (defn- local->parent
"The inverse of a node's time map: where a frame of its OWN time sits in the "Where a frame of a node's OWN time sits in the symbol it lives in.
symbol it lives in. See `node/time-of`. See `node/time-of`.
Two frame spaces meet at every node and mixing them up is the bug this exists Two frame spaces meet at every node and mixing them up is the bug this exists
to prevent: a placement `:at 48` whose scale is keyed at 0 has that key on to prevent: a placement `:at 48` whose scale is keyed at 0 has that key on
@ -56,10 +63,7 @@
frame the exposure grid never samples is still authored on that frame, and that frame the exposure grid never samples is still authored on that frame, and that
is where the row should show it." is where the row should show it."
[n] [n]
(let [{:keys [at rate]} (node/time-of n)] (time->parent (node/time-of n)))
(if (and (zero? at) (= 1 rate))
identity
(fn [f] (+ at (/ f rate))))))
(defn- keyed-frames [ch] (some-> (:keys ch) keys sort)) (defn- keyed-frames [ch] (some-> (:keys ch) keys sort))
@ -131,8 +135,8 @@
;; the key positions, and the bars that would imply them. Refuse ;; the key positions, and the bars that would imply them. Refuse
;; rather than guess, without refusing the whole subtree. ;; rather than guess, without refusing the whole subtree.
(when-let [source (and (= :instance (:kind n)) (node/source n))] (when-let [source (and (= :instance (:kind n)) (node/source n))]
(if-let [{:keys [at rate]} (clip/source-time clip sid n)] (if-let [source-time (clip/source-time clip sid n)]
(walk source path depth (comp self #(+ at (/ % rate)))) (walk source path depth (comp self (time->parent source-time)))
(mapv (fn [row] (mapv (fn [row]
(-> row (-> row
(assoc :keys [] :unmapped? true) (assoc :keys [] :unmapped? true)
@ -244,6 +248,13 @@
self (comp parent-map (local->parent n)) self (comp parent-map (local->parent n))
source-sym (when (= :instance (:kind n)) source-sym (when (= :instance (:kind n))
(clip/symbol clip (node/source n))) (clip/symbol clip (node/source n)))
;; `self` maps the instance's local clock. Rows of
;; the symbol it places use its source clock too,
;; including the native-FPS ratio.
source-self (if-let [t (and source-sym
(clip/source-time clip sid n))]
(comp self (time->parent t))
self)
lane? (symbol/lane? source-sym) lane? (symbol/lane? source-sym)
clips (when lane? (symbol/children (:nodes source-sym))) clips (when lane? (symbol/children (:nodes source-sym)))
span (mapv parent-map span (mapv parent-map
@ -283,13 +294,13 @@
:label (or (get-in clip [:symbols (node/source child) :name]) :label (or (get-in clip [:symbols (node/source child) :name])
(some-> (node/source child) name)) (some-> (node/source child) name))
:source (node/source child) :source (node/source child)
:span (mapv self (node/placed-span child)) :span (mapv source-self (node/placed-span child))
;; The clip's own keys, on the ;; The clip's own keys, on the
;; block, so a collapsed lane ;; block, so a collapsed lane
;; still says where it changes. ;; still says where it changes.
:keys (into [] :keys (into []
(comp (mapcat keyed-frames) (comp (mapcat keyed-frames)
(map (comp self (local->parent child))) (map (comp source-self (local->parent child)))
(distinct)) (distinct))
(vals (node/channels child))) (vals (node/channels child)))
:select [:node (node/source n) (:id child) :select [:node (node/source n) (:id child)
@ -302,7 +313,7 @@
;; The one clip an expanded lane opens. ;; The one clip an expanded lane opens.
(into (when lane? (into (when lane?
(if-let [child (first (filter #(under? (conj rpath (:id %))) clips))] (if-let [child (first (filter #(under? (conj rpath (:id %))) clips))]
(portal (node/source n) rpath (inc depth) self child) (portal (node/source n) rpath (inc depth) source-self child)
[{:path (conj rpath ::portal) [{:path (conj rpath ::portal)
:depth (inc depth) :depth (inc depth)
:kind :hint :kind :hint
@ -587,7 +598,6 @@
(if (= :instance node-kind) " drop-into" " drop-group")))) (if (= :instance node-kind) " drop-into" " drop-group"))))
:style {:padding-left (str (+ 4 (* 11 depth)) "px")} :style {:padding-left (str (+ 4 (* 11 depth)) "px")}
:title label :title label
:tab-index (when lane? 0)
:ref (when (and select (= select selection)) (reveal selection)) :ref (when (and select (= select selection)) (reveal selection))
;; A LABEL AIMS. Clicking a row's name says "I am working ;; A LABEL AIMS. Clicking a row's name says "I am working
;; here", which is a statement about a place in the document; ;; here", which is a statement about a place in the document;
@ -597,13 +607,8 @@
:on-click #(when select (rf/dispatch [::ui/aim select])) :on-click #(when select (rf/dispatch [::ui/aim select]))
;; An instance's row opens the symbol it places, as a tab. ;; An instance's row opens the symbol it places, as a tab.
:on-double-click (fn [^js e] :on-double-click (fn [^js e]
(cond lane? (do (.stopPropagation e) (begin-rename!)) (when of
of (rf/dispatch [::pb/open-symbol of]))) (rf/dispatch [::pb/open-symbol of])))}
:on-key-down (when lane?
(fn [^js e]
(when (= "F2" (.-key e))
(.preventDefault e)
(begin-rename!))))}
;; A node's row can be dragged onto another: onto an instance's, to ;; A node's row can be dragged onto another: onto an instance's, to
;; go inside the symbol it places; onto any other node's, to be ;; go inside the symbol it places; onto any other node's, to be
;; grouped with it into a new one; onto an edge of either, to be ;; grouped with it into a new one; onto an edge of either, to be
@ -667,7 +672,7 @@
node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))]) node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))])
(when lane? (when lane?
[:button.tl-rename [:button.tl-rename
{:title "rename lane (F2)" {:title "rename lane"
:on-click (fn [^js e] (.stopPropagation e) (begin-rename!))} :on-click (fn [^js e] (.stopPropagation e) (begin-rename!))}
"✎"]) "✎"])
;; A face's row is where its own footage is switched on, next to solo ;; A face's row is where its own footage is switched on, next to solo
@ -734,10 +739,22 @@
(not= (:select under) (:selection drag))) (not= (:select under) (:selection drag)))
under) under)
[target-el target] (when (and drag (not shift?)) (lane-under e)) [target-el target] (when (and drag (not shift?)) (lane-under e))
landing? (some? target) target-at (when target
(frame-under e frames target-el))
;; A cel moved on its OWN lane is a normal slide.
;; Resolve the row under the pointer before calling
;; it a transfer: comparing the displayed rows is
;; not enough when the lane is reached through an
;; instance. This leaves one slide gesture and one
;; coordinate conversion; lane overlap trimming is
;; the domain command's only additional policy.
target-sid (when target
(:sid (ui/drop-destination-at
clip store open target-at target)))
landing? (and target-sid
(not= target-sid (nth (:selection drag) 1)))
target-frame (when landing? target-frame (when landing?
(max 0 (- (frame-under e frames target-el) (max 0 (- target-at (:grab drag))))]
(:grab drag))))]
(when (and drag hint) (when (and drag hint)
(reset! hint (reset! hint
{:x (.-clientX e) :y (.-clientY e) {:x (.-clientX e) :y (.-clientY e)

View file

@ -356,3 +356,15 @@
(is (= [22 23] (node/placed-span (get-in slid [:symbols :lane :nodes :b])))) (is (= [22 23] (node/placed-span (get-in slid [:symbols :lane :nodes :b]))))
(is (= 1 (:rate (get-in slid [:symbols :lane :nodes :b :time]))) (is (= 1 (:rate (get-in slid [:symbols :lane :nodes :b :time])))
"and the rest of its map is still there"))) "and the rest of its map is still there")))
(deftest sliding-in-a-lane-claims-overlapped-time
;; The gesture is `nest/slide` in both display modes. Lane mode contributes
;; only the placement rule at commit: the moved cel replaces what it covers.
(let [c (on-twos)
r (nest/slide c :lane [:b] -14)
slid (:clip r)]
(is (nil? (:refused r)) (:refused r))
(is (= [5 6] (node/placed-span (get-in slid [:symbols :lane :nodes :b]))))
(is (nil? (get-in slid [:symbols :lane :nodes :a]))
"a fully covered neighbor is removed")
(is (empty? (clip/problems slid)))))

View file

@ -63,6 +63,29 @@
[:node :main :insert [:insert]]] [:node :main :insert [:insert]]]
(mapv :select (:cels lane)))))) (mapv :select (:cels lane))))))
(deftest a-nested-lane-is-drawn-in-the-open-symbols-frame-rate
(let [child {:id :cel :kind :instance :z "a" :span [0 2]
:time {:at 4 :rate 1}
:source {:symbol :drawing}
:playback {:in 0 :speed 0 :end :stop}}
placed {:id :take :kind :instance :z "a" :span [0 12]
:time {:at 0 :rate 1}
:source {:symbol :lane}
:playback {:in 0 :speed 1 :end :stop}}
doc (-> (clip/blank)
(assoc :fps 12)
(assoc-in [:symbols :main :fps] 12)
(assoc-in [:symbols :main :frames] 12)
(assoc-in [:symbols :main :nodes] {:take placed})
(assoc-in [:symbols :lane]
{:id :lane :fps 24 :frames 24 :display :lane
:nodes {:cel child}})
(assoc-in [:symbols :drawing]
{:id :drawing :fps 24 :frames 1 :nodes {}}))
lane (first (filter :lane? (timeline/rows doc :main #{})))]
(is (= [[2 3]] (mapv :span (:cels lane)))
"native frames 4–6 occupy ruler frames 2–3 at twice the frame rate")))
(deftest an-ordinary-symbol-keeps-a-row-per-node (deftest an-ordinary-symbol-keeps-a-row-per-node
(let [doc (update-in (fixture/document) [:symbols :main] dissoc :display) (let [doc (update-in (fixture/document) [:symbols :main] dissoc :display)
rows (timeline/rows doc :main #{})] rows (timeline/rows doc :main #{})]