Unify root timing and persistent lane targets
This commit is contained in:
parent
f5a39aee39
commit
1b2b4ad3d2
11 changed files with 175 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`."
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 #{})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue