From 1b2b4ad3d245a8bf6a38d8a2d7a08f367b73df81 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 2 Oct 2026 01:11:55 -0400 Subject: [PATCH] Unify root timing and persistent lane targets --- docs/time.md | 4 ++ frontend/src/arthur/domain/clip.cljs | 39 ++++++++++++++++--- frontend/src/arthur/domain/span.cljs | 30 ++++++++++++-- frontend/src/arthur/events/project.cljs | 10 +++-- frontend/src/arthur/events/ui.cljs | 12 +++++- frontend/src/arthur/ui/params.cljs | 5 +++ frontend/src/arthur/ui/stage.cljs | 11 ++++-- frontend/src/arthur/ui/timeline.cljs | 32 +++++++++++---- frontend/test/arthur/domain/cadence_test.cljs | 30 ++++++++++++++ frontend/test/arthur/events/lane_test.cljs | 28 +++++++++++-- static/arthur/app.css | 7 ++-- 11 files changed, 175 insertions(+), 33 deletions(-) diff --git a/docs/time.md b/docs/time.md index 643dc17..c0d9205 100644 --- a/docs/time.md +++ b/docs/time.md @@ -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 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. +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: `floor(output-frame * native-fps / output-fps)`. Thus 30fps content in a 12fps diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index 5f5f807..d21eef6 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -71,12 +71,25 @@ (defn fps [clip sid] (or (:fps (symbol clip sid)) (:fps clip))) (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 - (update :symbols #(into {} (map (fn [[sid sym]] - [sid (assoc sym :fps (fps clip sid))])) %)) - (assoc :fps rate))) + (let [old (:fps clip)] + (-> clip + (update :symbols + #(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] (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)]) (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 "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." @@ -188,7 +213,9 @@ {:name "untitled" :fps 30 :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 "Put a symbol's already resolved mark into its instance's parent space. Its diff --git a/frontend/src/arthur/domain/span.cljs b/frontend/src/arthur/domain/span.cljs index 735f2e8..1efa712 100644 --- a/frontend/src/arthur/domain/span.cljs +++ b/frontend/src/arthur/domain/span.cljs @@ -49,6 +49,28 @@ [arthur.domain.node :as node] [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 "Commit `nodes` as symbol `sid`'s, or refuse. @@ -94,9 +116,11 @@ (and (> needed (:frames sym)) (= :keep extent)) {:refused (str "the edit needs " needed " frames; extend the shot to continue") :required-frames needed} - :else {:clip (cond-> (assoc-in clip [:symbols sid :nodes] nodes) - (> needed (:frames sym)) - (assoc-in [:symbols sid :frames] needed)) + :else {:clip (fit-lanes + (cond-> (assoc-in clip [:symbols sid :nodes] nodes) + (> needed (:frames sym)) + (assoc-in [:symbols sid :frames] needed)) + sid) :selection selection}))) ;; --------------------------------------------------------------------------- diff --git a/frontend/src/arthur/events/project.cljs b/frontend/src/arthur/events/project.cljs index d4ada08..e1089d6 100644 --- a/frontend/src/arthur/events/project.cljs +++ b/frontend/src/arthur/events/project.cljs @@ -113,7 +113,11 @@ loaded (project/load cid #js {:leaves (.-leaves clip-json) :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]) {:label (str (or (.-name clip-json) cid) " (saved)") :cid cid @@ -606,7 +610,7 @@ (if (or (not (#{:fps :width :height} key)) (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) frame (min (dec (pb/frames db')) (js/Math.floor (* (get-in db [:playback :frame]) @@ -636,7 +640,7 @@ (rf/reg-event-db ::symbol-setting (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)))) db (edit/edit db diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index ceefbb0..f8b0e15 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -624,6 +624,12 @@ (let [{document :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) 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) (assoc (nest/inside document st open [] frame) :path []) (aimed-symbol document st db frame)) @@ -665,8 +671,10 @@ (rf/reg-event-db ::new-lane (fn [db _] - ;; A lane is an explicit top-level track of the open symbol. It must not - ;; become nested merely because the previously created lane is still aimed. + ;; A lane is an explicit, persistent top-level track of the open symbol. It + ;; 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))) ;; --------------------------------------------------------------------------- diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index b01f246..3432be1 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -537,6 +537,11 @@ [:div.inspector-form [number-field "length (frames)" (:frames sym) #(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) #(rf/dispatch [::project/symbol-setting sid :width %]) "project default" busy?] [number-field "height" (:height sym) diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 8c419cf..689339e 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -137,12 +137,15 @@ (defn- select! "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 - 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] (let [{document :clip st :store} (loaded ctx)] - (rf/dispatch [::ui/select (when-let [{:keys [sid id]} (when (seq path) - (nest/placement document st open path f))] - [:node sid id path])]))) + (if-let [{:keys [sid id]} (when (seq path) + (nest/placement document st open path f))] + (rf/dispatch [::ui/select [:node sid id path]]) + (rf/dispatch [::ui/aim nil])))) (defn- begin! "Start dragging `kind` of the node at `path` from stage point `p`." diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 8ce0833..a05aac5 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -1051,8 +1051,11 @@ visible (cond-> (vec picture) (seq sounds) (-> (conj {:path [::sounds] :kind :section :label "audio"}) (into sounds))) - ;; Roughly ten labels, on a round number of frames. - step (* 10 (js/Math.ceil (/ frames 100))) + ;; Major marks are whole seconds in the OPEN symbol's clock. On long + ;; 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 ;; 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 @@ -1063,9 +1066,19 @@ [:section.pane.time [transport] [: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 ;; 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) (.preventDefault e) (set! (.. e -dataTransfer -dropEffect) "move"))) @@ -1083,7 +1096,10 @@ [label-cell row selection target over solo tracing renaming draft]) {:key (str (:path row))})))] [: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] (when (drag/accepts?) (.preventDefault e) @@ -1105,10 +1121,10 @@ :on-drop (fn [^js e] (.preventDefault e) (drag/land! (frame-at e frames) nil)) - ;; Five frames as a percentage of the whole span, handed to the - ;; stylesheet so the frame grid can be a repeating background instead - ;; of a div per frame. A 900-frame take is 900 elements nobody needs. - :style {"--tick" (str (* 100 (/ 5 frames)) "%")}} + ;; The rows and ruler share this exact major interval. Keeping a + ;; second, hard-coded five-frame grid here made its lines disagree + ;; with the numbered marks whenever the symbol's FPS changed. + :style {"--tick" (str (* 100 (/ step frames)) "%")}} [:div.tl-ruler {:on-pointer-down (fn [^js e] (rf/dispatch [::pb/seek (seek-to e frames)]) diff --git a/frontend/test/arthur/domain/cadence_test.cljs b/frontend/test/arthur/domain/cadence_test.cljs index 80be494..c32002b 100644 --- a/frontend/test/arthur/domain/cadence_test.cljs +++ b/frontend/test/arthur/domain/cadence_test.cljs @@ -45,6 +45,36 @@ (is (= 3 (clip/first-output-frame doc :main 7))) (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 ;; `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 diff --git a/frontend/test/arthur/events/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs index 8aade43..939ce3c 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -3,6 +3,7 @@ [arthur.domain.clip :as clip] [arthur.domain.history :as history] [arthur.domain.leaf :as leaf] + [arthur.domain.node :as node] [arthur.domain.sequence-test :as fixture] [arthur.domain.span :as span] [arthur.events.ui :as ui] @@ -16,20 +17,41 @@ (let [doc (clip/blank) id (store/install! {:clip doc :store {}} key)] (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) (let [db @rf-db/app-db saved (:clip (store/entry id)) [_ _ instance-id] (get-in db [:ui :selection]) 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") - {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 (= :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])) "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 (let [doc (fixture/document) rows (timeline/rows doc :main #{}) diff --git a/static/arthur/app.css b/static/arthur/app.css index 80a3afa..5a25d4b 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -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))); position: relative; background: #fff; - /* The frame grid, five frames to a division, as a background rather than as - an element per frame: a 900-frame take is 900 divs nobody needs in the DOM. - `--tick` is five frames as a percentage of the span, set from the component - because only it knows how long the clip is. */ + /* The major frame grid, using the same whole-second interval as the numbered + ruler. It is a background rather than an element per mark; `--tick` is set + by the component because only it knows the open symbol's length and FPS. */ background-image: repeating-linear-gradient(90deg, var(--grid-5) 0 1px, transparent 1px var(--tick, 10%));