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
;; first. `finish` does the same `problems` check and the overlap one
;; 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
"Move the right edge of the node at `path` by `df` frames of `open`.

View file

@ -144,6 +144,47 @@
[n which 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
"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
@ -335,7 +376,7 @@
(let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id)
[lo old-out] (when node (node/placed-span node))
later (when node
later (when (and node ripple?)
(filter #(>= (first (node/placed-span %)) old-out)
(siblings clip sid nodes id)))]
(cond
@ -346,22 +387,10 @@
:else
(let [delta (- to old-out)
resized (assoc nodes id (edged node :out to))
changed
(if ripple?
(reduce (fn [ns sibling]
(update-in ns [(:id sibling) :time :at] (fnil + 0) delta))
resized later)
(if (pos? delta)
(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)))))
changed (reduce (fn [ns sibling]
(update-in ns [(:id sibling) :time :at] (fnil + 0) delta))
resized later)]
(claim clip sid changed id extent nil)))))
(defn resize-in
"Put clip `id`'s left edge at parent frame `to`. Shrinking leaves a gap;
@ -369,29 +398,14 @@
[clip sid id to]
(let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id)
[old-in hi] (when node (node/placed-span node))
earlier (when node
(filter #(<= (second (node/placed-span %)) old-in)
(siblings clip sid nodes id)))]
[old-in hi] (when node (node/placed-span node))]
(cond
refused {:refused refused}
(not (integer? to)) {:refused "an edge goes to a whole frame"}
(not (< to hi)) {:refused "a clip must keep at least one frame"}
(neg? to) {:refused "a clip cannot begin before the shot"}
(= to old-in) {:clip clip :selection id}
:else
(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)))))
:else (claim clip sid (assoc nodes id (edged node :in to)) id :keep nil))))
(defn roll
"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.
(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
"Change one held clip's duration by `delta` frames and ripple its later
siblings. Keys, source clocks and the clips' own channels stay put.
@ -528,12 +531,8 @@
(and remainder-id (contains? nodes remainder-id))
{:refused "the remainder clip needs a free ID"}
:else
(let [room (cleared clip sid at duration remainder-id)]
(if (:refused room)
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)))))))
(let [placed (update-in n [:time :at] (fnil + 0) (- at lo))]
(claim clip sid (assoc nodes id placed) id extent remainder-id)))))
(defn place-symbol
"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"
(clip/symbol clip drawing-id) "the new drawing ID is already used")]
{:refused why}
(let [room (cleared clip sid at 1 remainder-id)]
(if (:refused room)
room
(place (assoc-in (:clip room) [:symbols drawing-id]
{:id drawing-id :name (name drawing-id) :fps (clip/fps clip sid) :frames 1 :nodes {}})
sid id drawing-id at extent false))))))
(let [c (assoc-in clip [:symbols drawing-id]
{:id drawing-id :name (name drawing-id)
:fps (clip/fps clip sid) :frames 1 :nodes {}})
nodes (assoc nodes id (held id drawing-id at))]
(claim c sid nodes id extent remainder-id)))))
(defn make-unique
"Point clip `id` at a private copy of its content, leaving every other

View file

@ -696,9 +696,9 @@
::drop-clear
(fn [db _] (update db :ui dissoc :drop)))
(defn drop-destination
"Which symbol a drop lands in and on which of its frames: `{:clip :sid :at
:path}`, or `{:refused why}`.
(defn drop-destination-at
"Which symbol a drop on `target` lands in and on which of its frames:
`{:clip :sid :at :path}`, or `{:refused why}`.
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
@ -714,12 +714,11 @@
`: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
thing."
[db document st frame target]
[document st open frame target]
(let [target (if (vector? target)
(let [[_ sid id path] target]
{:sid sid :id id :path path})
target)
open (get-in db [:ui :open])
path (cond
(nil? 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"}
: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
"`db` after a drop that produced `result`, with `uuid` selected."
[db {:keys [sid path]} uuid result]

View file

@ -43,9 +43,16 @@
;; ---------------------------------------------------------------------------
;; 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
"The inverse of a node's time map: where a frame of its OWN time sits in the
symbol it lives in. See `node/time-of`.
"Where a frame of a node's OWN time sits in the symbol it lives in.
See `node/time-of`.
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
@ -56,10 +63,7 @@
frame the exposure grid never samples is still authored on that frame, and that
is where the row should show it."
[n]
(let [{:keys [at rate]} (node/time-of n)]
(if (and (zero? at) (= 1 rate))
identity
(fn [f] (+ at (/ f rate))))))
(time->parent (node/time-of n)))
(defn- keyed-frames [ch] (some-> (:keys ch) keys sort))
@ -131,8 +135,8 @@
;; the key positions, and the bars that would imply them. Refuse
;; rather than guess, without refusing the whole subtree.
(when-let [source (and (= :instance (:kind n)) (node/source n))]
(if-let [{:keys [at rate]} (clip/source-time clip sid n)]
(walk source path depth (comp self #(+ at (/ % rate))))
(if-let [source-time (clip/source-time clip sid n)]
(walk source path depth (comp self (time->parent source-time)))
(mapv (fn [row]
(-> row
(assoc :keys [] :unmapped? true)
@ -244,6 +248,13 @@
self (comp parent-map (local->parent n))
source-sym (when (= :instance (:kind 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)
clips (when lane? (symbol/children (:nodes source-sym)))
span (mapv parent-map
@ -283,13 +294,13 @@
:label (or (get-in clip [:symbols (node/source child) :name])
(some-> (node/source child) name))
: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
;; block, so a collapsed lane
;; still says where it changes.
:keys (into []
(comp (mapcat keyed-frames)
(map (comp self (local->parent child)))
(map (comp source-self (local->parent child)))
(distinct))
(vals (node/channels child)))
:select [:node (node/source n) (:id child)
@ -302,7 +313,7 @@
;; The one clip an expanded lane opens.
(into (when lane?
(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)
:depth (inc depth)
:kind :hint
@ -587,7 +598,6 @@
(if (= :instance node-kind) " drop-into" " drop-group"))))
:style {:padding-left (str (+ 4 (* 11 depth)) "px")}
:title label
:tab-index (when lane? 0)
:ref (when (and select (= select selection)) (reveal selection))
;; A LABEL AIMS. Clicking a row's name says "I am working
;; here", which is a statement about a place in the document;
@ -597,13 +607,8 @@
:on-click #(when select (rf/dispatch [::ui/aim select]))
;; An instance's row opens the symbol it places, as a tab.
:on-double-click (fn [^js e]
(cond lane? (do (.stopPropagation e) (begin-rename!))
of (rf/dispatch [::pb/open-symbol of])))
:on-key-down (when lane?
(fn [^js e]
(when (= "F2" (.-key e))
(.preventDefault e)
(begin-rename!))))}
(when of
(rf/dispatch [::pb/open-symbol of])))}
;; 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
;; 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)))])
(when lane?
[:button.tl-rename
{:title "rename lane (F2)"
{:title "rename lane"
:on-click (fn [^js e] (.stopPropagation e) (begin-rename!))}
"✎"])
;; A face's row is where its own footage is switched on, next to solo
@ -734,10 +739,22 @@
(not= (:select under) (:selection drag)))
under)
[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?
(max 0 (- (frame-under e frames target-el)
(:grab drag))))]
(max 0 (- target-at (:grab drag))))]
(when (and drag hint)
(reset! hint
{: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 (= 1 (:rate (get-in slid [:symbols :lane :nodes :b :time])))
"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]]]
(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
(let [doc (update-in (fixture/document) [:symbols :main] dissoc :display)
rows (timeline/rows doc :main #{})]