Unify root timing and persistent lane targets

This commit is contained in:
Your Name 2026-10-02 01:11:55 -04:00
parent f5a39aee39
commit 1b2b4ad3d2
11 changed files with 175 additions and 33 deletions

View file

@ -4,6 +4,10 @@ Project `:fps` is the playback and export grid. Each symbol has its own native
`:fps` and `:frames`; keys, spans, trace choices and corrections stay in that `:fps` and `:frames`; keys, spans, trace choices and corrections stay in that
native space. A symbol without an explicit rate inherits the document rate; native space. A symbol without an explicit rate inherits the document rate;
changing project fps first records that rate so its existing timing stays put. changing project fps first records that rate so its existing timing stays put.
The untouched symbol in a new document is deliberately different: it has no
authored timing to preserve, so it stays on the project grid and its empty frame
extent is rescaled to keep the same duration. This makes changing fps before
authoring establish the editor's grid instead of preserving the 30fps default.
An output frame selects the latest native frame at or before its time: An output frame selects the latest native frame at or before its time:
`floor(output-frame * native-fps / output-fps)`. Thus 30fps content in a 12fps `floor(output-frame * native-fps / output-fps)`. Thus 30fps content in a 12fps

View file

@ -71,12 +71,25 @@
(defn fps [clip sid] (or (:fps (symbol clip sid)) (:fps clip))) (defn fps [clip sid] (or (:fps (symbol clip sid)) (:fps clip)))
(defn set-fps (defn set-fps
"Change the output grid without rewriting any content's frames." "Change the output grid without rewriting authored content's frames.
A new document's empty symbol is the one exception: it has no native rate yet,
so it follows the project grid and its empty extent is rescaled to preserve its
duration. Once a symbol contains anything, changing the project rate records
the old effective rate on it before changing the output grid."
[clip rate] [clip rate]
(-> clip (let [old (:fps clip)]
(update :symbols #(into {} (map (fn [[sid sym]] (-> clip
[sid (assoc sym :fps (fps clip sid))])) %)) (update :symbols
(assoc :fps rate))) #(into {}
(map (fn [[sid sym]]
[sid (cond
(:fps sym) sym
(empty? (:nodes sym))
(update sym :frames cadence/frames rate old)
:else (assoc sym :fps old))]))
%))
(assoc :fps rate))))
(defn output-frames [clip sid] (defn output-frames [clip sid]
(cadence/frames (frames clip sid) (:fps clip) (fps clip sid))) (cadence/frames (frames clip sid) (:fps clip) (fps clip sid)))
@ -165,6 +178,18 @@
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)]) (first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
(unplaced clip)))) (unplaced clip))))
(defn set-root-fps
"Set the document/output rate and the root symbol's editing rate together.
Project FPS is the root timeline's clock. Nested symbols keep their own native
rates and are sampled when placed across that boundary; only the root changes
here. Frame numbers are authored positions, so changing the rate does not
rewrite them or silently move cuts and keys."
[clip rate]
(let [root (opens-on clip)]
(cond-> (assoc clip :fps rate)
root (assoc-in [:symbols root :fps] rate))))
(def ^:const blank-frames (def ^:const blank-frames
"How long a new document is before anything says otherwise. Four seconds at 30, "How long a new document is before anything says otherwise. Four seconds at 30,
which is long enough to key something into and short enough to scrub by hand." which is long enough to key something into and short enough to scrub by hand."
@ -188,7 +213,9 @@
{:name "untitled" {:name "untitled"
:fps 30 :fps 30
:width 320 :height 200 :width 320 :height 200
:symbols {:main {:id :main :fps 30 :frames blank-frames :nodes {}}}}) ;; No native fps yet: an untouched canvas follows the project grid. Imported
;; and generated symbols carry their own rate explicitly.
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
(defn- transform-op (defn- transform-op
"Put a symbol's already resolved mark into its instance's parent space. Its "Put a symbol's already resolved mark into its instance's parent space. Its

View file

@ -49,6 +49,28 @@
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.symbol :as symbol])) [arthur.domain.symbol :as symbol]))
(defn- fit-lanes
"Make direct lane children cover `sid`'s authored window.
A lane has no independently authored extent: it is a view of its parent
symbol's timeline. Keep that invariant at the one commit point that can grow
a symbol, so neither the wrapper nor the lane symbol retains an old parent
length."
[clip sid]
(let [frames (clip/frames clip sid)]
(reduce
(fn [c [id n]]
(let [source (node/source n)]
(if (and (nil? (:parent n))
(symbol/lane? (clip/symbol c source)))
(-> c
(assoc-in [:symbols sid :nodes id :span] [0 frames])
(assoc-in [:symbols sid :nodes id :time :at] 0)
(assoc-in [:symbols source :frames] frames))
c)))
clip
(get-in clip [:symbols sid :nodes]))))
(defn finish (defn finish
"Commit `nodes` as symbol `sid`'s, or refuse. "Commit `nodes` as symbol `sid`'s, or refuse.
@ -94,9 +116,11 @@
(and (> needed (:frames sym)) (= :keep extent)) (and (> needed (:frames sym)) (= :keep extent))
{:refused (str "the edit needs " needed " frames; extend the shot to continue") {:refused (str "the edit needs " needed " frames; extend the shot to continue")
:required-frames needed} :required-frames needed}
:else {:clip (cond-> (assoc-in clip [:symbols sid :nodes] nodes) :else {:clip (fit-lanes
(> needed (:frames sym)) (cond-> (assoc-in clip [:symbols sid :nodes] nodes)
(assoc-in [:symbols sid :frames] needed)) (> needed (:frames sym))
(assoc-in [:symbols sid :frames] needed))
sid)
:selection selection}))) :selection selection})))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------

View file

@ -113,7 +113,11 @@
loaded (project/load loaded (project/load
cid #js {:leaves (.-leaves clip-json) cid #js {:leaves (.-leaves clip-json)
:blocks blocks}) :blocks blocks})
built (:clip loaded)] ;; Project FPS and the root timeline are one clock. This
;; also normalizes documents saved by the earlier model,
;; where changing project FPS left the root on its old
;; editing grid.
built (clip/set-root-fps (:clip loaded) (:fps (:clip loaded)))]
(let [entry (merge (select-keys built [:fps :width :height]) (let [entry (merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)") {:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid :cid cid
@ -606,7 +610,7 @@
(if (or (not (#{:fps :width :height} key)) (if (or (not (#{:fps :width :height} key))
(not (and (integer? value) (pos? value)))) (not (and (integer? value) (pos? value))))
{} {}
(let [db' (edit/edit db #(if (= key :fps) (clip/set-fps % value) (assoc % key value))) (let [db' (edit/edit db #(if (= key :fps) (clip/set-root-fps % value) (assoc % key value)))
db' (assoc-in db' [:clip key] value) db' (assoc-in db' [:clip key] value)
frame (min (dec (pb/frames db')) frame (min (dec (pb/frames db'))
(js/Math.floor (* (get-in db [:playback :frame]) (js/Math.floor (* (get-in db [:playback :frame])
@ -636,7 +640,7 @@
(rf/reg-event-db (rf/reg-event-db
::symbol-setting ::symbol-setting
(fn [db [_ sid key value]] (fn [db [_ sid key value]]
(if-not (and (#{:frames :width :height} key) (if-not (and (#{:frames :fps :width :height} key)
(or (nil? value) (and (integer? value) (pos? value)))) (or (nil? value) (and (integer? value) (pos? value))))
db db
(edit/edit db (edit/edit db

View file

@ -624,6 +624,12 @@
(let [{document :clip st :store} (store/entry (:clip/current db)) (let [{document :clip st :store} (store/entry (:clip/current db))
open (get-in db [:ui :open]) open (get-in db [:ui :open])
frame (editing-frame db document) frame (editing-frame db document)
;; A lane is the open symbol's timeline, not an insert that happens to
;; begin where the playhead was when it was made. Giving that wrapper
;; the whole open-symbol window makes the row's promise true: it is a
;; destination at every frame. Ordinary symbols remain clips created at
;; the playhead.
frame (if lane? 0 frame)
into (if (= :top where) into (if (= :top where)
(assoc (nest/inside document st open [] frame) :path []) (assoc (nest/inside document st open [] frame) :path [])
(aimed-symbol document st db frame)) (aimed-symbol document st db frame))
@ -665,8 +671,10 @@
(rf/reg-event-db (rf/reg-event-db
::new-lane ::new-lane
(fn [db _] (fn [db _]
;; A lane is an explicit top-level track of the open symbol. It must not ;; A lane is an explicit, persistent top-level track of the open symbol. It
;; become nested merely because the previously created lane is still aimed. ;; must not become nested merely because the previously created lane is
;; still aimed, and it spans the open symbol rather than starting at the
;; current playhead.
(create-container db :top true))) (create-container db :top true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------

View file

@ -537,6 +537,11 @@
[:div.inspector-form [:div.inspector-form
[number-field "length (frames)" (:frames sym) [number-field "length (frames)" (:frames sym)
#(rf/dispatch [::project/symbol-setting sid :frames %]) nil busy?] #(rf/dispatch [::project/symbol-setting sid :frames %]) nil busy?]
[number-field "fps" (clip-domain/fps clip sid)
#(rf/dispatch (if (= sid (clip-domain/opens-on clip))
[::project/project-setting :fps %]
[::project/symbol-setting sid :fps %]))
nil busy?]
[number-field "width" (:width sym) [number-field "width" (:width sym)
#(rf/dispatch [::project/symbol-setting sid :width %]) "project default" busy?] #(rf/dispatch [::project/symbol-setting sid :width %]) "project default" busy?]
[number-field "height" (:height sym) [number-field "height" (:height sym)

View file

@ -137,12 +137,15 @@
(defn- select! (defn- select!
"Select the node at row path `path` of the open symbol — the selection a "Select the node at row path `path` of the open symbol — the selection a
timeline row makes, so the row, the inspector and the stage all show it — or timeline row makes, so the row, the inspector and the stage all show it — or
nothing." the open symbol itself when `path` is empty. Blank stage is an explicit place,
not merely an absence of a picked shape, so it clears a stale drawing target
as well as the inspector selection."
[{:keys [open f] :as ctx} path] [{:keys [open f] :as ctx} path]
(let [{document :clip st :store} (loaded ctx)] (let [{document :clip st :store} (loaded ctx)]
(rf/dispatch [::ui/select (when-let [{:keys [sid id]} (when (seq path) (if-let [{:keys [sid id]} (when (seq path)
(nest/placement document st open path f))] (nest/placement document st open path f))]
[:node sid id path])]))) (rf/dispatch [::ui/select [:node sid id path]])
(rf/dispatch [::ui/aim nil]))))
(defn- begin! (defn- begin!
"Start dragging `kind` of the node at `path` from stage point `p`." "Start dragging `kind` of the node at `path` from stage point `p`."

View file

@ -1051,8 +1051,11 @@
visible (cond-> (vec picture) visible (cond-> (vec picture)
(seq sounds) (-> (conj {:path [::sounds] :kind :section :label "audio"}) (seq sounds) (-> (conj {:path [::sounds] :kind :section :label "audio"})
(into sounds))) (into sounds)))
;; Roughly ten labels, on a round number of frames. ;; Major marks are whole seconds in the OPEN symbol's clock. On long
step (* 10 (js/Math.ceil (/ frames 100))) ;; timelines use a whole-number multiple of a second to keep roughly
;; ten labels; never invent an FPS-blind 20/40/60 ruler.
fps (max 1 (or (clip/fps clip open) 1))
step (* fps (max 1 (js/Math.ceil (/ frames (* fps 10)))))
;; THE OTHER DIRECTION, AND THE ONLY PLACE THIS PANE GOES THERE. The ;; THE OTHER DIRECTION, AND THE ONLY PLACE THIS PANE GOES THERE. The
;; ruler is in the open symbol's frames and the transport counts output ;; ruler is in the open symbol's frames and the transport counts output
;; frames, so scrubbing names a mark and seeks to the output frame that ;; frames, so scrubbing names a mark and seeks to the output frame that
@ -1063,9 +1066,19 @@
[:section.pane.time [:section.pane.time
[transport] [transport]
[:div.tl-body [:div.tl-body
;; Blank timeline space means the open symbol. Use the same `aim`
;; gesture as its breadcrumb so both the visible selection and the
;; drawing destination return to the root. Child controls stop their
;; own events; the target check keeps ordinary row/bar clicks local.
{:on-click (fn [^js e]
(when (= (.-target e) (.-currentTarget e))
(rf/dispatch [::ui/aim nil])))}
[:div.tl-labels [:div.tl-labels
;; Empty label space takes a row back out to the top of the open symbol. ;; Empty label space takes a row back out to the top of the open symbol.
{:on-drag-over (fn [^js e] {:on-click (fn [^js e]
(when (= (.-target e) (.-currentTarget e))
(rf/dispatch [::ui/aim nil])))
:on-drag-over (fn [^js e]
(when (drag/row) (when (drag/row)
(.preventDefault e) (.preventDefault e)
(set! (.. e -dataTransfer -dropEffect) "move"))) (set! (.. e -dataTransfer -dropEffect) "move")))
@ -1083,7 +1096,10 @@
[label-cell row selection target over solo tracing renaming draft]) [label-cell row selection target over solo tracing renaming draft])
{:key (str (:path row))})))] {:key (str (:path row))})))]
[:div.tl-tracks [:div.tl-tracks
{:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) {:on-click (fn [^js e]
(when (= (.-target e) (.-currentTarget e))
(rf/dispatch [::ui/aim nil])))
:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e)))
:on-drag-over (fn [^js e] :on-drag-over (fn [^js e]
(when (drag/accepts?) (when (drag/accepts?)
(.preventDefault e) (.preventDefault e)
@ -1105,10 +1121,10 @@
:on-drop (fn [^js e] :on-drop (fn [^js e]
(.preventDefault e) (.preventDefault e)
(drag/land! (frame-at e frames) nil)) (drag/land! (frame-at e frames) nil))
;; Five frames as a percentage of the whole span, handed to the ;; The rows and ruler share this exact major interval. Keeping a
;; stylesheet so the frame grid can be a repeating background instead ;; second, hard-coded five-frame grid here made its lines disagree
;; of a div per frame. A 900-frame take is 900 elements nobody needs. ;; with the numbered marks whenever the symbol's FPS changed.
:style {"--tick" (str (* 100 (/ 5 frames)) "%")}} :style {"--tick" (str (* 100 (/ step frames)) "%")}}
[:div.tl-ruler [:div.tl-ruler
{:on-pointer-down (fn [^js e] {:on-pointer-down (fn [^js e]
(rf/dispatch [::pb/seek (seek-to e frames)]) (rf/dispatch [::pb/seek (seek-to e frames)])

View file

@ -45,6 +45,36 @@
(is (= 3 (clip/first-output-frame doc :main 7))) (is (= 3 (clip/first-output-frame doc :main 7)))
(is (= [0 60] (:span (first (timeline/rows doc :main #{}))))))) (is (= [0 60] (:span (first (timeline/rows doc :main #{})))))))
(deftest changing-fps-before-authoring-moves-the-empty-canvas-to-that-grid
(let [doc (clip/set-fps (clip/blank) 12)]
(is (= 12 (:fps doc)))
(is (nil? (get-in doc [:symbols :main :fps])))
(is (= 48 (clip/frames doc :main)))
(is (= 48 (clip/output-frames doc :main)))
(is (= 8 (clip/shown-frame doc :main 8)))
(is (= 8 (clip/first-output-frame doc :main 8)))))
(deftest changing-fps-after-authoring-preserves-the-symbols-native-grid
(let [started (assoc-in (clip/blank) [:symbols :main :nodes :mark]
{:id :mark :kind :rect :z "a"})
doc (clip/set-fps started 12)]
(is (= 30 (get-in doc [:symbols :main :fps])))
(is (= 120 (clip/frames doc :main)))
(is (= 48 (clip/output-frames doc :main)))))
(deftest project-fps-is-the-root-symbols-editing-grid
(let [doc (-> (clip/blank)
(assoc-in [:symbols :main :nodes :child]
{:id :child :kind :instance :z "a"
:source {:symbol :nested}})
(assoc-in [:symbols :nested]
{:id :nested :fps 30 :frames 90 :nodes {}})
(clip/set-root-fps 12))]
(is (= 12 (:fps doc)) "the output grid")
(is (= 12 (clip/fps doc :main)) "is also the root editing grid")
(is (= 30 (clip/fps doc :nested)) "while a nested symbol keeps its own grid")
(is (= 120 (clip/frames doc :main)) "frame positions are not rewritten")))
(deftest crossing-to-the-output-grid-and-back-lands-on-the-frame-it-names (deftest crossing-to-the-output-grid-and-back-lands-on-the-frame-it-names
;; `first-output-frame` is the inverse of `shown-frame` as far as a floor has ;; `first-output-frame` is the inverse of `shown-frame` as far as a floor has
;; one: seeking to the output frame it names puts the playhead on a frame at or ;; one: seeking to the output frame it names puts the playhead on a frame at or

View file

@ -3,6 +3,7 @@
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.history :as history] [arthur.domain.history :as history]
[arthur.domain.leaf :as leaf] [arthur.domain.leaf :as leaf]
[arthur.domain.node :as node]
[arthur.domain.sequence-test :as fixture] [arthur.domain.sequence-test :as fixture]
[arthur.domain.span :as span] [arthur.domain.span :as span]
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
@ -16,20 +17,41 @@
(let [doc (clip/blank) (let [doc (clip/blank)
id (store/install! {:clip doc :store {}} key)] id (store/install! {:clip doc :store {}} key)]
(reset! rf-db/app-db {:clip/current id :paint/revision 0 (reset! rf-db/app-db {:clip/current id :paint/revision 0
:ui {:open :main} :playback {:frame 0}}) :ui {:open :main} :playback {:frame 6}})
(rf/dispatch-sync event) (rf/dispatch-sync event)
(let [db @rf-db/app-db (let [db @rf-db/app-db
saved (:clip (store/entry id)) saved (:clip (store/entry id))
[_ _ instance-id] (get-in db [:ui :selection]) [_ _ instance-id] (get-in db [:ui :selection])
sid (get-in saved [:symbols :main :nodes instance-id :source :symbol])] sid (get-in saved [:symbols :main :nodes instance-id :source :symbol])]
{:db db :symbol (clip/symbol saved sid)})))] {:db db :document saved :instance-id instance-id
:symbol (clip/symbol saved sid)})))]
(let [{ordinary :symbol} (run [::ui/new-symbol :inside] "explicit-symbol") (let [{ordinary :symbol} (run [::ui/new-symbol :inside] "explicit-symbol")
{lane :symbol lane-db :db} (run [::ui/new-lane] "explicit-lane")] {lane :symbol lane-db :db document :document instance-id :instance-id}
(run [::ui/new-lane] "explicit-lane")]
(is (nil? (:display ordinary)) "new symbol means ordinary symbol") (is (nil? (:display ordinary)) "new symbol means ordinary symbol")
(is (= :lane (:display lane)) "only the lane command creates a lane") (is (= :lane (:display lane)) "only the lane command creates a lane")
(is (= [0 (clip/frames document :main)]
(node/placed-span (get-in document [:symbols :main :nodes instance-id])))
"a lane exists across the open symbol, independent of the playhead")
(let [longer (assoc-in document [:symbols :main :frames] 300)
fitted (:clip (span/finish longer :main
(get-in longer [:symbols :main :nodes])
nil :keep))
lane-id (node/source (get-in fitted [:symbols :main :nodes instance-id]))]
(is (= [0 300]
(node/placed-span (get-in fitted [:symbols :main :nodes instance-id])))
"the lane follows a later change to its parent's extent")
(is (= 300 (clip/frames fitted lane-id))))
(is (some? (get-in lane-db [:ui :target])) (is (some? (get-in lane-db [:ui :target]))
"the new lane is aimed so drawing and pool drops can go into it")))) "the new lane is aimed so drawing and pool drops can go into it"))))
(deftest aiming-the-root-clears-selection-and-drawing-target
(let [db {:ui {:selection [:node :main :shape [:lane :shape]]
:target {:sid :main :id :lane :path [:lane]}}}
after (ui/aimed db nil)]
(is (nil? (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target])))))
(deftest an-explicit-lane-is-one-row-of-clips (deftest an-explicit-lane-is-one-row-of-clips
(let [doc (fixture/document) (let [doc (fixture/document)
rows (timeline/rows doc :main #{}) rows (timeline/rows doc :main #{})

View file

@ -1026,10 +1026,9 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
min-width: max(340px, calc((100% - var(--label)) * var(--tl-zoom, 1))); min-width: max(340px, calc((100% - var(--label)) * var(--tl-zoom, 1)));
position: relative; position: relative;
background: #fff; background: #fff;
/* The frame grid, five frames to a division, as a background rather than as /* The major frame grid, using the same whole-second interval as the numbered
an element per frame: a 900-frame take is 900 divs nobody needs in the DOM. ruler. It is a background rather than an element per mark; `--tick` is set
`--tick` is five frames as a percentage of the span, set from the component by the component because only it knows the open symbol's length and FPS. */
because only it knows how long the clip is. */
background-image: background-image:
repeating-linear-gradient(90deg, repeating-linear-gradient(90deg,
var(--grid-5) 0 1px, transparent 1px var(--tick, 10%)); var(--grid-5) 0 1px, transparent 1px var(--tick, 10%));