diff --git a/docs/one-grid-plan.md b/docs/one-grid-plan.md new file mode 100644 index 0000000..90414ce --- /dev/null +++ b/docs/one-grid-plan.md @@ -0,0 +1,66 @@ +# The editing grid is the symbol's own frames + +## What was wrong + +Every number a person authors — a span, a `:time :at`, a key, a cut — is in the +frame space of the symbol it lives in (`docs/time.md`, and that part is right). +The timeline, though, drew its ruler in OUTPUT frames: `clip/output-frames`, the +transport's length. In a 12fps project holding 30fps symbols those two spaces sit +at a ratio of 2.5, so: + +* a clip at symbol frame 31 was drawn at ruler frame 12.4 — **no clip edge landed + on a frame mark**, because almost none of them can; +* a gesture measured in ruler frames had to be multiplied into the symbol's + frames and rounded (`nest/dragged`), so dragging one ruler frame moved the clip + 2 or 3 symbol frames — **0.8 or 1.2 ruler frames, never the 1 the pointer + said**. That is the jumpiness; +* the preview drew the gesture's own number of ruler frames while the commit + wrote the rounded one, so **the ghost sat somewhere the settled clip did not**; +* before the rounding was added, the fraction went into the document and every + later edge edit on that clip was refused for ever (`span/*` refuses a + fractional edge, as it should). + +One cause, four symptoms. None of them is an edge case to patch. + +## The model + +1. **A symbol's own frames are the only coordinate anything authored lives in.** + Unchanged. +2. **The editor edits in the open symbol's frames.** The ruler, the marks, the + playhead's position on it, every pointer→frame answer, every drop frame and + every drag delta are the open symbol's frames. No multiplication anywhere in + the gesture path, so no rounding and nothing fractional to refuse. +3. **The output grid is playback's alone** — the clock, the audio mix, export, + and the frame the stage draws. Exactly two pure functions cross between them + and nothing else does: + * `clip/shown-frame clip sid f` — which of `sid`'s frames output frame `f` + shows (`cadence/frame`: the latest at or before it). + * `clip/first-output-frame clip sid n` — the output frame that first shows + symbol frame `n`; the inverse, for seeking from the ruler. +4. `nest/inside`, `nest/placement`, `nest/spans` and the gestures take the + subject symbol's OWN frame. They used to take an output frame and multiply it + secretly, which is what made every caller's units a guess. A caller holding + the playhead converts with `clip/shown-frame`, at its own edge, visibly. + +## The gestures + +5. **One pointer→frame function** for the whole timeline, `frame-under`. A drag's + delta is the difference of two of its answers, never a pixel ratio rounded + separately — so the preview and the commit are the same number by + construction. +6. **The junction between two clips is one handle with one meaning**: roll. It + moves the end of the left clip and the start of the right one together, which + is `span/roll`, which is already nothing but `resize-out` then `resize-in`. + The three 4px-wide zones it used to pick between — trim-left, roll, + trim-right, inside twelve pixels — were the "it just picks one" the handle + was accused of. An edge that is not shared still has its own in/out handles. + +## Audio goes with the picture it belongs to + +A take's sound lived inside the take symbol, so placing the take brought it and +placing the FACE the take is made of brought nothing. A symbol now says what it +sounds like — `:audio`, a sound source — and placing one places a linked audio +node beside the clip. Detection sets it on the face it extracts, which is the +automatic link; `::ui/link-audio` sets or clears it by hand, which is the manual +one. `:linked-to` on the audio node already existed and already follows a moved +picture. diff --git a/frontend/src/arthur/domain/cadence.cljs b/frontend/src/arthur/domain/cadence.cljs index 736d7d5..28b60cb 100644 --- a/frontend/src/arthur/domain/cadence.cljs +++ b/frontend/src/arthur/domain/cadence.cljs @@ -13,3 +13,13 @@ "Reader frames covering a native length, including a partial final frame." [n grid native] (when n (max 1 (js/Math.ceil (/ n (ratio grid native)))))) + +(defn reader-frame + "The earliest reader frame whose `frame` is at or after native frame n — the + inverse of `frame`, as far as it has one. + + `frame` is a floor, so several reader frames can show one native frame and a + native frame between two of them is shown by none: this answers with the + reader frame that first reaches it, which is what seeking to a mark means." + [n grid native] + (js/Math.ceil (if (and grid native) (/ (* n grid) native) n))) diff --git a/frontend/src/arthur/domain/clip.cljs b/frontend/src/arthur/domain/clip.cljs index fee987c..5f5f807 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -84,6 +84,27 @@ (defn grid-time [clip sid] {:at 0 :rate (cadence/ratio (:fps clip) (fps clip sid))}) +;; --------------------------------------------------------------------------- +;; the only crossing between the output grid and a symbol's own frames +;; +;; Everything authored is in a symbol's own frames and every editing gesture is +;; too — see `docs/one-grid-plan.md`. The output grid belongs to playback: the +;; clock, the audio mix, export, and the frame number the stage is drawing. These +;; two functions are the whole of the way between, so a caller that has a +;; playhead and needs a document coordinate says so in one visible call instead +;; of multiplying by a rate it had to know about. + +(defn shown-frame + "Which of symbol `sid`'s own frames the output frame `f` shows." + [clip sid f] + (cadence/frame f (:fps clip) (fps clip sid))) + +(defn first-output-frame + "The output frame that first shows symbol `sid`'s own frame `n`: what to seek + to to put the playhead on a mark of `sid`'s ruler." + [clip sid n] + (cadence/reader-frame n (:fps clip) (fps clip sid))) + (defn source-time "Derived cel-to-content map. Frame-rate units never enter stored retimes." [clip host n] diff --git a/frontend/src/arthur/domain/nest.cljs b/frontend/src/arthur/domain/nest.cljs index 9936314..8673ef8 100644 --- a/frontend/src/arthur/domain/nest.cljs +++ b/frontend/src/arthur/domain/nest.cljs @@ -39,13 +39,20 @@ r)) (defn inside - "Walk row path `path` down from symbol `sid`, whose frame `f` is showing, into - the node it ends at. Returns `{:sid :frame :matrix :time}`: the symbol that + "Walk row path `path` down from symbol `sid`, whose OWN frame `f` is showing, + into the node it ends at. Returns `{:sid :frame :matrix :time}`: the symbol that node places (nil for one that places none), the frame of its own it is showing, the matrix from its coordinates to `sid`'s, and the time map from `sid`'s frames to its own — or nil when a node on the way is not on screen at that frame, where there is no inside to be in. + `f` IS ONE OF `sid`'S OWN FRAMES, which is the coordinate everything authored + and every editing gesture is in — see `docs/one-grid-plan.md`. It used to be an + OUTPUT frame, multiplied into `sid`'s space here, which made the units of the + argument something each caller had to know without being told and put a + 2.5-frame step between what the ruler offered and what the document could hold. + A caller holding the playhead converts with `clip/shown-frame`. + THE SAME STEP FOR EVERY NODE. Inside an instance is the symbol it places; inside a shape is where its points and keys are. Either way it is the node's own coordinates and frames, so a shape any depth down is edited through the @@ -84,8 +91,8 @@ (cond-> (reduce node/then-time time (map node/time-of chain)) inst? (node/then-time (clip/source-time clip sid (get nodes id)))))} (reduced nil)))) - {:sid sid :frame (js/Math.floor (* f (:rate (clip/grid-time clip sid)))) - :matrix (node/mat) :time (clip/grid-time clip sid)} + {:sid sid :frame (js/Math.floor f) + :matrix (node/mat) :time node/same-time} path)) (defn placement @@ -353,7 +360,28 @@ sids path)] {:sid (last sids) :time (when (every? some? maps) - (reduce node/then-time (clip/grid-time clip sid) maps))})) + (reduce node/then-time node/same-time maps))})) + +(defn- dragged + "Frame `from` of node `id`'s own space, dragged `df` frames of the open + symbol: ONE WHOLE FRAME of that space. + + THE ONE PLACE A RULER GESTURE BECOMES A FRAME NUMBER, and the whole of it. + `df` counts the open symbol's own frames, which is what the ruler is drawn in, + so for a row of the open symbol itself this is `from + df` and nothing happens + here at all. It earns its keep for a row reached THROUGH a retimed instance, + where one frame of the ruler is a fraction of the node's own: a frame number is + the one thing in a document that cannot fall between two frames — + `span/resize-out`, `resize-in` and `roll` refuse a fractional edge outright, + and `slide` would have let it into `:time :at` and refused every later edge + edit on that clip for ever. + + Rounding, not flooring: a drag is a gesture at a position, and the frame it + means is the nearer one in both directions. Nothing else belongs here." + [here nodes id from df] + (let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) + rate (:rate (reduce node/then-time (:time here) (map node/time-of chain)))] + (js/Math.round (+ from (* df rate))))) (defn slide "Move the node at row path `path` along its symbol's time by `df` frames of @@ -370,10 +398,23 @@ (nil? (get nodes id)) {:refused "nothing to move"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) - d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain)))) + (let [n (get nodes id) + ;; Measured from where the node STARTS, so what lands on a whole + ;; frame is the thing you can see moving. A node with no span is + ;; on screen throughout and only its `:at` moves. + from (or (first (node/placed-span n)) (get-in n [:time :at] 0)) + d (- (dragged here nodes id from df) from) + ;; MOVE THE MAP THAT IS THERE; write a fresh one only where there is + ;; none. The test for "there is one" is `:time` itself, and whether + ;; it is the affine kind is `node/mapped-time?` — which an absent + ;; `:mode` satisfies, because `node/time-of` has always read it as + ;; `:map`. Asking for the key instead said no to every clip + ;; `span/held` makes, whose `:time` is `{:at f :rate 1}` and nothing + ;; more, and the else branch then REPLACED that map with one built + ;; from `d` alone: the first drag of a freshly drawn clip threw away + ;; its `:at` and jumped it to the head of the lane. shift (fn [n] - (if (= :map (get-in n [:time :mode])) + (if (and (:time n) (node/mapped-time? n)) (update-in n [:time :at] (fnil + 0) d) (assoc n :time {:mode :map :at d :rate 1}))) moved (assoc nodes id (shift (get nodes id))) @@ -385,11 +426,20 @@ (if (and (= :audio (:kind n)) (= id (:linked-to n))) (assoc ns audio-id (shift n)) ns)) - moved nodes)) - ps (symbol/problems (assoc (clip/symbol clip (:sid here)) :nodes moved))] - (if (seq ps) - {:refused (first ps)} - {:clip (assoc-in clip [:symbols (:sid here) :nodes] moved)}))))) + moved nodes))] + ;; COMMITTED LIKE EVERY OTHER SPAN EDIT. This validated with + ;; `symbol/problems` and wrote the nodes in itself, which made it a + ;; SECOND commit path — and the one `span/finish`'s docstring says is + ;; the only one: "no command can commit an overlap in lane mode, and + ;; `symbol/overlaps` turning up anything is a bug in a command rather + ;; than a state to design around". It was that bug. A body drag could + ;; leave two clips of a lane on screen over the same frames, and from + ;; there the lane stops behaving: `symbol/children` has two clips + ;; claiming one frame, so which drawing a polygon lands in and whether + ;; 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))))) (defn resize-out "Move the right edge of the node at `path` by `df` frames of `open`. @@ -407,10 +457,8 @@ (nil? n) {:refused "nothing to resize"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) - d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain)))) - to (+ (second (node/placed-span n)) d)] - (span/resize-out clip sid id to {:ripple? ripple? :extent :grow-symbol}))))) + (span/resize-out clip sid id (dragged here nodes id (second (node/placed-span n)) df) + {:ripple? ripple? :extent :grow-symbol})))) (defn resize-in "Move the left edge of the node at `path` by `df` frames of `open`." @@ -424,10 +472,7 @@ (nil? n) {:refused "nothing to resize"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) - d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain)))) - to (+ (first (node/placed-span n)) d)] - (span/resize-in clip sid id to))))) + (span/resize-in clip sid id (dragged here nodes id (first (node/placed-span n)) df))))) (defn roll "Move the shared boundary at `right-path` and the adjacent `left-path`." @@ -443,10 +488,8 @@ {:refused "a rolling edit needs adjacent clips in one sequence"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes right-id)))) - d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain)))) - to (+ (first (node/placed-span right)) d)] - (span/roll clip sid left-id right-id to))))) + (span/roll clip sid left-id right-id + (dragged here nodes right-id (first (node/placed-span right)) df))))) (defn restack "Put the node at row path `from` just in front of the one at `to` when diff --git a/frontend/src/arthur/domain/node.cljs b/frontend/src/arthur/domain/node.cljs index 3bb5be5..e23715f 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -161,6 +161,12 @@ [f n] (if (and n (> n 1)) (* (js/Math.floor (/ f n)) n) f)) +(def same-time + "The identity time map: these frames ARE those frames. What a walk starts from + before it has composed anything, and what `time-of` gives a node with no time + of its own." + {:at 0 :rate 1}) + (defn time-of "A node's own time as the affine map it is: `{:at a :rate r}`, meaning a frame `p` of its parent is frame `r·(p − a)` of its own. THE SAME FOR EVERY NODE. A @@ -179,6 +185,20 @@ {:at (- at (/ offset rate)) :rate rate} {:at 0 :rate 1}))) +(defn mapped-time? + "Whether `n`'s own time is the affine map `time-of` reads, and therefore + whether moving it is a write to `:time :at`. + + AN ABSENT `:mode` IS `:map`, which is what `time-of` has always defaulted it + to — and the default is the ordinary case, not an edge one: `span/held` writes + `{:at f :rate 1}` with no mode at all, so every clip a drawing creates is in + it. Code that asked `(= :map (get-in n [:time :mode]))` instead answered no + for those, and `nest/slide` acted on the answer by REPLACING the whole map + with a fresh one — so the first drag of a freshly drawn clip discarded its + `:at` and teleported it to the head of the lane." + [n] + (= :map (:mode (:time n) :map))) + (defn then-time "`outer` then `inner`: the map from `outer`'s parent straight to `inner`'s own frames. Time maps compose like matrices do, which is what makes a nesting of diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 14516ce..ceefbb0 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -32,6 +32,22 @@ [arthur.footage.store :as store] [re-frame.core :as rf])) +(defn editing-frame + "The frame of the open symbol the playhead is showing: the coordinate every + command in here works in, and the only thing `[:playback :frame]` is read for. + + THE ONE CONVERSION, AND IT IS IN ONE DIRECTION. `[:playback :frame]` counts the + OUTPUT grid — the clock's, the audio's, export's — and everything authored is in + the frames of the symbol it lives in. A 12fps project holding 30fps symbols puts + 2.5 of those frames in one of these, so a command handed the raw playhead was + being handed a number in the wrong units; `clip/shown-frame` is which frame of + the symbol that output frame actually shows. See `docs/one-grid-plan.md`. + + `::playback/seek` takes the output frame back, through + `clip/first-output-frame`." + [db clip] + (clip/shown-frame clip (get-in db [:ui :open]) (get-in db [:playback :frame]))) + (rf/reg-event-db ::rename-node (fn [db [_ sid id value]] @@ -48,7 +64,14 @@ The rows above a selection are opened, so one made deep on the stage is seen in the timeline. Not a sound's: its row is always in the audio section, and - opening the placement it is heard through would bury it." + opening the placement it is heard through would bury it. + + AN EMPTY PATH IS A REAL SELECTION, not a missing one. The open symbol's own + lane row names itself with `[]`, and `(pop [])` THROWS — so clicking that row + aborted the whole event: nothing was selected, nothing was aimed, and the next + polygon went wherever the stale target still pointed, which is most of what + made aiming a lane feel random. There is nothing above a selection at the top + to open, so the guard is `seq` rather than `some?`." [db selection] (let [[kind sid id path] selection sound? (= :audio (get-in (store/entry (:clip/current db)) @@ -56,7 +79,7 @@ (cond-> (-> db (assoc-in [:ui :selection] selection) (update :ui dissoc :points :retry)) - (and (= :node kind) path (not sound?)) + (and (= :node kind) (seq path) (not sound?)) (update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path))))))) (defn target-of @@ -173,12 +196,18 @@ [db sid result retry] (let [{clip :clip st :store} (store/entry (:clip/current db)) path (nth (get-in db [:ui :selection]) 3 nil) - {:keys [at rate]} (:time (nest/inside clip st (get-in db [:ui :open]) + open (get-in db [:ui :open]) + {:keys [at rate]} (:time (nest/inside clip st open (if (seq path) (pop path) []) - (get-in db [:playback :frame])))] + (editing-frame db clip)))] (cond-> {:db (apply-command db sid result retry)} (and (:clip result) (:frame result) rate) - (assoc :dispatch [::playback/seek (+ at (/ (:frame result) rate))])))) + ;; Out of the made node's frames into the open symbol's, then out of those + ;; into the output frame the transport seeks in — the one place that second + ;; step is needed, because this is the one command that moves the playhead. + (assoc :dispatch [::playback/seek + (clip/first-output-frame clip open + (+ at (/ (:frame result) rate)))])))) (defn selection-frame "The playhead as a frame of the symbol that owns `selection`. @@ -232,7 +261,7 @@ selection (get-in db [:ui :selection]) [_ sid] selection at (selection-frame clip st (get-in db [:ui :open]) selection - (get-in db [:playback :frame])) + (editing-frame db clip)) result (if (integer? at) (span/append-drawing clip sid (random-uuid) (clip/fresh-id clip) {:at at :extent (or extent :keep)}) @@ -253,7 +282,7 @@ {:clip clip :sid sid :id id :node (get-in clip [:symbols sid :nodes id]) :at (when-let [owner-frame (selection-frame clip st (get-in db [:ui :open]) selection - (get-in db [:playback :frame]))] + (editing-frame db clip))] (span/host-frame clip sid id owner-frame))})) (def ^:private no-frame @@ -275,7 +304,7 @@ selection (get-in db [:ui :selection]) [_ sid] selection at (selection-frame clip st (get-in db [:ui :open]) selection - (get-in db [:playback :frame])) + (editing-frame db clip)) result (if (integer? at) (span/overwrite-drawing clip sid (random-uuid) (clip/fresh-id clip) at {:extent (or extent :keep) @@ -466,13 +495,15 @@ (cond (not (integer? frame)) {:refused "the playhead is not on one frame of this symbol"} - exposed {:clip clip :path (conj (vec path) (:id exposed))} + exposed {:clip clip :sid sid :path (conj (vec path) (:id exposed))} :else (let [cel-id (random-uuid) made (span/overwrite-drawing clip sid cel-id (clip/fresh-id clip) frame {:extent :grow-symbol :remainder-id (random-uuid)})] - (if (:refused made) made {:clip (:clip made) :path (conj (vec path) cel-id)}))))) + (if (:refused made) + made + {:clip (:clip made) :sid sid :path (conj (vec path) cel-id)}))))) (defn polygon-landing "Choose the document and row path a finished polygon is drawn into. @@ -483,10 +514,10 @@ drawing the playhead is on. In an ordinary composition the polygon goes straight into the symbol, as it always did." [clip st db] - (let [into (aimed-symbol clip st db (get-in db [:playback :frame]))] + (let [into (aimed-symbol clip st db (editing-frame db clip))] (if (and (:sid into) (symbol/lane? (clip/symbol clip (:sid into)))) (assoc (into-the-sequence clip db into) :lane? true) - {:clip clip :path (:path into) :lane? false}))) + {:clip clip :sid (:sid into) :path (:path into) :lane? false}))) (defn beginning-polygon "Enter polygon mode, first materializing a drawing at the playhead when the @@ -497,7 +528,6 @@ cancels only the draft; the explicitly started drawing remains." [db] (let [{clip :clip st :store} (store/entry (:clip/current db)) - open (get-in db [:ui :open]) landing (polygon-landing clip st db) source (:clip landing) made? (and source (not (identical? clip source))) @@ -508,8 +538,14 @@ made? (-> db (edit/transaction (constantly source)) + ;; THE LANE'S SYMBOL, NOT THE OPEN ONE. The clip just made + ;; lives in the symbol the lane draws, which is only the open + ;; symbol when the lane IS what is open. Naming the wrong one + ;; left a selection that looked up to nothing, so the + ;; inspector, the breadcrumb and every span command went + ;; blank on a drawing that had just been created. (assoc-in [:ui :selection] - [:node open + [:node (:sid landing) (peek (:path landing)) (:path landing)])) :else db)] @@ -534,7 +570,7 @@ down (:path landing) {:keys [sid frame pts]} (when-not (:refused landing) - (nest/drawn-inside source st open down (get-in db [:playback :frame]) draft))] + (nest/drawn-inside source st open down (editing-frame db source) draft))] (cond (< (count draft) 6) db (:refused landing) (update db :project merge {:status (:refused landing)}) @@ -550,13 +586,19 @@ ;; log ever has to reproduce a document exactly, this becomes a cofx. :else (let [id (keyword (str "paint-" (random-uuid)))] + ;; NOTHING IS OPENED HERE. Finishing a shape used to expand every row + ;; between the open symbol and the new shape, which inside a lane meant + ;; tearing the lane's one row open into its portal, that clip's + ;; channels and every shape already in the drawing — a dozen rows, in + ;; answer to a gesture that said nothing about the outline. Expansion + ;; is the twist triangle's business and nobody else's; the shape is + ;; selected and on the stage with handles on it, which is where you + ;; were looking. (-> db (edit/transaction (fn [_] (paint/new-shape source sid id frame pts (get-in db [:ui :tone])))) (update :ui merge {:tool nil :draft [] - :selection [:node sid id (conj down id)]}) - (update-in [:ui :expanded] (fnil into #{}) - (rest (reductions conj [] down))))))))) + :selection [:node sid id (conj down id)]}))))))) (rf/reg-event-db ::set-knob @@ -581,7 +623,7 @@ [db where lane?] (let [{document :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) - frame (get-in db [:playback :frame]) + frame (editing-frame db document) into (if (= :top where) (assoc (nest/inside document st open [] frame) :path []) (aimed-symbol document st db frame)) @@ -657,6 +699,10 @@ is created to receive the drop: the symbol IS the container, so the first drop into one is the same operation as the second. + `frame` is a frame of the OPEN symbol — the ruler's own unit, which is what the + pointer answered with — and `:at` is that frame carried down into the + destination's. Nothing on this path crosses the output grid. + `: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." @@ -717,11 +763,14 @@ uuid (random-uuid)] (if (:refused where) (-> db (update :ui dissoc :drop) (update :project merge {:status (:refused where)})) + ;; `:at` IS ALREADY THE DESTINATION SYMBOL'S OWN FRAME, which is what + ;; `place-sound` positions in. It used to be multiplied by the grid rate + ;; again on the way in — a second conversion of an already-converted + ;; number, which dropped a sound 2.5 frames late for every frame it was + ;; aimed at in a 12fps project. (let [sid (:sid where) seeded (clip/place-sound (:clip where) sid source label length rate - (* (:at where) - (:rate (clip/grid-time (:clip where) sid))) - uuid)] + (:at where) uuid)] (landed db where uuid (span/adopt seeded sid uuid (:at where) {:extent :grow-symbol :remainder-id (random-uuid)}))))))) @@ -757,7 +806,7 @@ (fn [db [_ from to]] (let [{clip :clip st :store} (store/entry (:clip/current db)) r (nest/move-node clip st (get-in db [:ui :open]) from to - (get-in db [:playback :frame]))] + (editing-frame db clip))] (if-let [why (:refused r)] (refused db why) (-> db @@ -825,14 +874,20 @@ (update db :ui dissoc :gesture))))) (defn record-auto-frame - "Buffer the active gesture at outer playback frame `f`, mapped to the node's - local frame. Repeated pointer events replace that frame's sample cheaply." + "Buffer the active gesture at output playback frame `f`, mapped to the node's + local frame. Repeated pointer events replace that frame's sample cheaply. + + `f` is the TRANSPORT's frame here, because this is called from the pointer + stream while something plays, and the gesture names the symbol it started in — + so the crossing into that symbol's own frames happens here, against that + symbol, rather than in the caller against whatever happens to be open." [db f] (let [{:keys [auto-key? open path values] :as g} (get-in db [:ui :gesture])] (if (and auto-key? (seq values)) (let [{document :clip st :store} (store/entry (:clip/current db))] - (if-let [{:keys [sid id frame]} (nest/placement document st open path f)] + (if-let [{:keys [sid id frame]} (nest/placement document st open path + (clip/shown-frame document open f))] (assoc-in db [:ui :gesture] (-> g (assoc :sid sid :id id :frame frame) @@ -877,7 +932,7 @@ (fn [db [_ from to front?]] (let [{clip :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) - f (get-in db [:playback :frame]) + f (editing-frame db clip) host (pop to) moved (if (= host (pop from)) {:clip clip :id (peek from)} @@ -897,7 +952,7 @@ (fn [db [_ froms]] (let [{clip :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) - f (get-in db [:playback :frame]) + f (editing-frame db clip) host (pop (first froms)) uuid (random-uuid) r (nest/group clip st open froms (clip/fresh-id clip) uuid f)] diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index fabeb39..b4df292 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -80,8 +80,31 @@ ::frames :<- [::clip] :<- [::open] + ;; THE TRANSPORT'S LENGTH, in output frames: what the clock counts, what the + ;; readout reads and what export writes. The timeline's length is + ;; `::open-frames`, which is a different number whenever the open symbol was + ;; authored on a different grid — see `docs/one-grid-plan.md`. (fn [[document sid] _] (clip/output-frames document sid))) +(rf/reg-sub + ::open-frames + :<- [::clip] + :<- [::open] + ;; How many frames the open symbol HAS, in its own frames: the ruler's length, + ;; because the ruler is the symbol's own frame space and every span drawn + ;; against it is in that space. + (fn [[document sid] _] (or (clip/frames document sid) 0))) + +(rf/reg-sub + ::open-frame + :<- [::clip] + :<- [::open] + :<- [::playback/frame] + ;; Where the playhead is IN THE OPEN SYMBOL — the one crossing from the output + ;; grid for everything that draws or edits in the timeline, and an integer + ;; frame of the symbol rather than a fraction between two of them. + (fn [[document sid f] _] (clip/shown-frame document sid f))) + (rf/reg-sub ::exposure :<- [::symbol] diff --git a/frontend/src/arthur/subs/ui.cljs b/frontend/src/arthur/subs/ui.cljs index 38a4c33..65fd08c 100644 --- a/frontend/src/arthur/subs/ui.cljs +++ b/frontend/src/arthur/subs/ui.cljs @@ -44,7 +44,7 @@ :<- [::selection] :<- [::render/clip-id] :<- [::render/open] - :<- [::playback/frame] + :<- [::render/open-frame] (fn [[[_ id n] [_ _ _ path] clip-id open f] _] ;; `nest/inside` the selected node, from the open symbol: its own frame, the ;; matrix from its coordinates to the stage's, and the time map from the open @@ -72,7 +72,7 @@ :<- [::render/clip] :<- [::render/clip-id] :<- [::render/open] - :<- [::playback/frame] + :<- [::render/open-frame] (fn [[{:keys [path]} [_ id n] clip clip-id open f] _] ;; `nest/placement` of the TARGET, with the bounds of what it draws — the ;; same work `::selected-placement` does, kept separate because the two are @@ -90,7 +90,7 @@ :<- [::render/clip] :<- [::render/clip-id] :<- [::render/open] - :<- [::playback/frame] + :<- [::render/open-frame] (fn [[[_ id n] [_ _ _ path] clip clip-id open f] _] ;; `nest/placement` of the selected node, and `:bounds` around what it draws ;; in its own coordinates, for the stage's handles. From the clip as it is diff --git a/frontend/src/arthur/ui/icon.cljs b/frontend/src/arthur/ui/icon.cljs index c91f827..d83239f 100644 --- a/frontend/src/arthur/ui/icon.cljs +++ b/frontend/src/arthur/ui/icon.cljs @@ -16,11 +16,13 @@ step's worth of tree-shaking, and a license file, for forty lines. Icons are used ONLY where the word is worse than the picture: the transport, - where `|<` was ASCII pretending to be a glyph, and the two playback toggles, - which are state rather than actions. Everything else in the strip keeps its - word, because `insert` and `overwrite` have no pictures and inventing some - would be a puzzle rather than a shorthand. Every one carries an `aria-label` - at the call site; nothing here is the only statement of what a control does." + where `|<` was ASCII pretending to be a glyph, the two playback toggles, which + are state rather than actions, and a lane's row in the timeline, where the + picture is read while scanning a column of twenty names and the word would + not be. Everything else in the strip keeps its word, because `insert` and + `overwrite` have no pictures and inventing some would be a puzzle rather than + a shorthand. Every one carries an `aria-label` or a text label beside it at + the call site; nothing here is the only statement of what a control does." (:require [clojure.string :as str])) ;; A glyph is its paths on a 12x12 grid. Solid shapes are filled; the two @@ -40,7 +42,12 @@ :sound {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"] :stroke ["M8 4.1a2.7 2.7 0 0 1 0 3.8"]} :muted {:fill ["M2.4 4.6h1.8L6.4 2.6v6.8L4.2 7.4H2.4z"] - :stroke ["M7.9 4.4 10.7 7.6" "M10.7 4.4 7.9 7.6"]}}) + :stroke ["M7.9 4.4 10.7 7.6" "M10.7 4.4 7.9 7.6"]} + ;; A strip divided into cels: what a lane IS, drawn rather than named, so one + ;; row out of a dozen says at a glance that its blocks follow one another in + ;; time instead of being on screen together. Whole and half coordinates, so + ;; the three dividers land on pixels at 11px. + :lane {:stroke ["M1.5 3.5h9v5h-9z" "M4.5 3.5v5" "M7.5 3.5v5"]}}) (defn view "The glyph named `k`, sized by CSS and inked in `currentColor` — so a button's diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index 6a58b05..b01f246 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -212,7 +212,7 @@ ;; A span is in the node's OWN frames and `at` is where its frame 0 sits ;; in this symbol. See `node/placed-span`. "span" (when start (str start " … " end)) - "at" (when (= :map (get-in n [:time :mode])) (str (get-in n [:time :at] 0)))] + "at" (when (node/mapped-time? n) (str (get-in n [:time :at] 0)))] (when (:paint? n) [drawing-keys sid id n @(rf/subscribe [::sub/selected-local])]) [:div.row {:style {:margin-top "6px"}} [:span.dim "channels"]] (let [{:keys [frame]} @(rf/subscribe [::sub/selected-local])] diff --git a/frontend/src/arthur/ui/stage.cljs b/frontend/src/arthur/ui/stage.cljs index 626bc35..8c419cf 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -270,7 +270,10 @@ [_ _ _ selected] @(rf/subscribe [::sub/selection]) clip-id @(rf/subscribe [::render/clip-id]) ctx {:clip-id clip-id - :open @(rf/subscribe [::render/open]) :f @(rf/subscribe [::playback/frame]) + ;; The open symbol's OWN frame, which is what `nest/placement` + ;; walks in: picking and dragging on the stage are edits, and an + ;; edit's coordinate is the document's. + :open @(rf/subscribe [::render/open]) :f @(rf/subscribe [::render/open-frame]) :w w :h h} points? @(rf/subscribe [::sub/points]) [sid id geom active editable? frame matrix] (when points? (editing)) @@ -352,7 +355,9 @@ ;; reallocates the backing store — so this re-rendering costs nothing per frame. (let [w @(rf/subscribe [::playback/width]) h @(rf/subscribe [::playback/height]) - frame @(rf/subscribe [::playback/frame]) + ;; A drop lands at the playhead IN THE OPEN SYMBOL's frames, the same + ;; unit a drop on the timeline's ruler lands in. + frame @(rf/subscribe [::render/open-frame]) zoom @(rf/subscribe [::layout/zoom :stage])] [:div.stage-area [:div.stage-wrap diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 1b6e8d9..8ce0833 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -314,7 +314,11 @@ sound-lane? (mapv #(assoc % :sound? true)))))) ordered))))] (if (get-in clip [:symbols sid]) - (walk sid [] 0 #(/ % (:rate (clip/grid-time clip sid)))) + ;; IDENTITY AT THE TOP. The ruler is this symbol's own frames, so a row of + ;; its own children needs no mapping at all; `->open` only earns its keep + ;; further down, where a row reached through an instance is in some other + ;; symbol's frames. + (walk sid [] 0 identity) []))))) (defn sound-rows @@ -328,12 +332,20 @@ inside the symbols this one places, mapped into this ruler." [clip sid expanded] (if-not (get-in clip [:symbols sid]) [] - (let [lane? (symbol/lane? (clip/symbol clip sid))] + (let [lane? (symbol/lane? (clip/symbol clip sid)) + ;; THE MIXER'S INTERVALS ARE IN OUTPUT FRAMES and this ruler is in the + ;; symbol's own, so these rows -- and only these -- convert. + ;; `nest/audio-tracks` is the mixer's own flattening and audio is + ;; measured in seconds, which is why it keeps the transport's grid. A + ;; sound's bar may land between two marks, and that is honest: a sound + ;; does not have to start on a frame boundary. + own (let [rate (:rate (clip/grid-time clip sid))] #(some-> % (* rate))) + own-span (fn [sp] (when-let [[a b] sp] [(own a) (own b)]))] (vec (mapcat (fn [[path tracks]] (let [n (first tracks) - span (node/placed-span n) + span (own-span (node/placed-span n)) select [:node (:owner n) (:id n) path] open? (contains? expanded path) via (when (< 1 (count path)) (str (first path))) @@ -341,14 +353,15 @@ :kind :node :node-kind :audio :via via :slides (if via (subvec path 0 1) path) :select select :expandable? true :expanded? open? :span span - :keys (vec (distinct (mapcat keyed-frames (vals (:channels n)))))}] + :keys (mapv own (distinct (mapcat keyed-frames (vals (:channels n)))))}] (cons (cond-> row (< 1 (count tracks)) (assoc :cels (mapv (fn [i track] {:id i :label (node-label (:id track) track) - :span (node/placed-span track) :select select}) + :span (own-span (node/placed-span track)) + :select select}) (range) tracks))) - (when open? (channel-rows n path 1 identity span))))) + (when open? (channel-rows n path 1 own span))))) (sort-by (comp str key) (group-by :path (remove #(and lane? (= 1 (count (:path %)))) @@ -363,17 +376,28 @@ (defn- at% [f frames] (str (* 100 (/ (+ f 0.5) (max 1 frames))) "%")) (defn- edge% [f frames] (str (* 100 (/ f (max 1 frames))) "%")) -(defn- frame-at - "Which frame the pointer is over." - [^js event frames] - (let [box (.getBoundingClientRect (.-currentTarget event)) +(defn- frame-under + "Which frame the pointer is over, measured in `element`'s box. + + THE ONE ANSWER EVERY GESTURE IN THIS PANE IS BUILT FROM, and the only place a + pixel becomes a frame. A drop lands on it, a scrub seeks to it, and a drag's + distance is the difference between two of its answers -- so the preview and the + commit cannot disagree, because they are the same subtraction. The alternative, + which this replaces, was a pixel delta scaled and rounded on its own: it + answered 1 where the pointer had crossed two marks and the ghost drew one + number while the command wrote another. + + Clamped into the ruler: a frame off either end is not a frame, and a gesture + dragged past the end and back again should come back to the frame it is over." + [^js event frames ^js element] + (let [box (.getBoundingClientRect element) x (- (.-clientX event) (.-left box))] (-> (/ (* x frames) (.-width box)) js/Math.floor (max 0) (min (dec frames))))) -(defn- frame-at-element [^js event frames ^js element] - (let [box (.getBoundingClientRect element) - x (- (.-clientX event) (.-left box))] - (-> (/ (* x frames) (.-width box)) js/Math.floor (max 0) (min (dec frames))))) +(defn- frame-at + "`frame-under`, in the box of the element whose handler is asking." + [^js event frames] + (frame-under event frames (.-currentTarget event))) (defn- lane-under "The lane track geometrically under a captured pointer, and its selection." @@ -423,8 +447,11 @@ ;; children, and nothing in a span says. span? (and n (not= :group (:kind n)) (some? (node/placed-span n))) ;; Every one of them acts at the playhead, so every one is offered only - ;; where the playhead is somewhere it means something. - owner-frame (ui/selection-frame clip st open selection frame) + ;; where the playhead is somewhere it means something -- and in the open + ;; symbol's own frames, which is what the commands take. `frame` and + ;; `frames` above are the transport's own numbers and are only read out. + owner-frame (ui/selection-frame clip st open selection + (clip/shown-frame clip open frame)) ;; TWO COORDINATES AND THEY ARE NOT THE SAME. `host` is the frame of ;; whatever the selection is positioned in — its lane, for a cel; the ;; symbol, for a free placement — and is what the span commands take. @@ -551,6 +578,7 @@ [over-path where] @over] [:div (cond-> {:class (str "tl-label" (when (and select (= select selection)) " on") (when aimed? " aimed") + (when lane? " lane") (when (= :ghost kind) " ghost") (when (= path over-path) (case where @@ -615,6 +643,12 @@ (.stopPropagation e) (rf/dispatch [::ui/toggle-row path]))} (when expandable? (if expanded? "▾" "▸"))] + ;; A LANE IS NOT AN INSTANCE WEARING A DIFFERENT HAT, to read. Its one row + ;; holds blocks that follow one another in time, where every other row + ;; holds a bar that is simply on screen — so it gets the strip glyph and + ;; its own ground, and the kind tag below says "lane" rather than naming + ;; the placement mechanism that happens to carry it. + (when lane? [:span.tl-ico {:aria-hidden true} [icon/view :lane]]) (if editing? [:input.tl-name-input {:value @draft :auto-focus true :aria-label "lane name" @@ -628,7 +662,9 @@ "Escape" (do (.preventDefault e) (reset! renaming nil)) nil))}] [:span.name label]) - (when node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))]) + (cond + lane? [:span.kind "·lane"] + node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))]) (when lane? [:button.tl-rename {:title "rename lane (F2)" @@ -660,16 +696,22 @@ "×"])])) (defn- track-cell - "`sliding` is the pointer's side of a bar being dragged, `{:path :x :width - :df}`. What it looks like mid-drag is `[:ui :sliding]`, which the clip every - row and the stage are drawn from already has in it." + "`sliding` is the gesture in flight: `{:row :path :kind :from :df}`, where + `:from` is the frame the press was on and `:df` how many frames the pointer has + moved since. What it looks like mid-drag is `[:ui :sliding]`, which the clip + every row and the stage are drawn from already has in it." [{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped?]} - frames sliding hint {:keys [clip store open frame]}] + frames sliding hint {:keys [clip store open frame selection]}] (let [active-row (:row @sliding) slide (fn [^js e] - (let [{from :row x0 :x width :width} @sliding] + (let [{from :row pressed :from} @sliding] (when (= path from) - (let [df (js/Math.round (/ (* frames (- (.-clientX e) x0)) (max 1 width))) + ;; HOW MANY FRAMES THE POINTER HAS MOVED, as the difference + ;; of two frames under it -- not a pixel distance scaled and + ;; rounded by itself. The ghost below and the command on + ;; release are handed this same number, so what the drag + ;; shows is what the drag does. + (let [df (- (frame-under e frames (.-currentTarget e)) pressed) drag (:drag @sliding) ;; TWO INTENTIONS, SAID BY A MODIFIER. An ordinary ;; body drag moves a clip in TIME, within its lane @@ -694,7 +736,7 @@ [target-el target] (when (and drag (not shift?)) (lane-under e)) landing? (some? target) target-frame (when landing? - (max 0 (- (frame-at-element e frames target-el) + (max 0 (- (frame-under e frames target-el) (:grab drag))))] (when (and drag hint) (reset! hint @@ -766,17 +808,22 @@ :on-click actual-select :drag (when drag (assoc drag - :grab (- (frame-at-element e frames track) + :grab (- (frame-under e frames track) (:in drag)))) :ripple? (and (= :out gesture-kind) (.-shiftKey e)) - :x (.-clientX e) :df 0 - :width (.-width (.getBoundingClientRect track))}) + ;; The frame pressed, which every later + ;; answer is measured from. No pixel origin + ;; and no track width: the box is measured + ;; when it is asked, so a pane resized or + ;; zoomed mid-drag cannot skew the gesture. + :from (frame-under e frames track) :df 0}) (try (.setPointerCapture track (.-pointerId e)) (catch :default _ nil))))] [:div.tl-track ;; The track, not the bar, holds the pointer while a bar slides, so the drag ;; goes on when the bar has slid off the ruler and is no longer drawn. - {:on-pointer-move slide + {:class (when lane? "lane") + :on-pointer-move slide :on-pointer-up (fn [e] (slide e) (done true)) :on-pointer-cancel (fn [_] (done false)) ;; THE CLICK ARRIVES HERE, not on the block it started on, because the @@ -824,6 +871,7 @@ (when (= :audio node-kind) " sound") (when unmapped? " unmapped") (when (and select (not unmapped?)) " movable") + (when (and select (= select selection)) " on") (when (= path active-row) " sliding")) :title (when unmapped? "inside a held clip · its own frames have no place on this ruler") @@ -856,7 +904,13 @@ [:button.tl-cel {:title (str label " · select clip; double-click to edit its symbol" " · shift-drag another clip onto it to nest that clip inside") + ;; WHICH CLIP IS SELECTED IS THE QUESTION THIS ROW ANSWERS. An + ;; expanded lane opens exactly the selected clip, so a block that + ;; cannot say whether it is the one leaves the portal below it + ;; unexplained. It wears the pick colour rather than the accent every + ;; span and drop already uses — see `--pick` in app.css. :class (str (when ghost? "ghost") + (when (and select (= select selection)) " on") (when (and select (= select (get-in @sliding [:nest :select]))) " nest-target")) :style {:position "absolute" :left (edge% in frames) @@ -870,25 +924,39 @@ (when el (aset el "arthurCel" {:select select :source source :label label :in (js/Math.floor in)})))) + ;; `:in` IS A RULER FRAME AND THEREFORE WHOLE, as the ref above + ;; already says. `:grab` is the pointer's offset into the block, + ;; `press − in`, and `target-frame` is `move − grab`, so a fractional + ;; `in` would make every landing frame fractional. A clip of this + ;; symbol now starts on a whole frame of this ruler by construction -- + ;; they are the same frame space -- and the floor is what still holds + ;; for a row reached through a retimed instance, where the clip really + ;; does begin between two of the ruler's frames. :on-pointer-down (when select #(begin! % (nth select 3) :slide select nil - {:selection select :label label :in in + {:selection select :label label + :in (js/Math.floor in) :duration (- out in)}))} [:span.tl-cel-label label] (when select (if joined? + ;; ONE HANDLE, ONE MEANING: the cut moves. The end of the clip on the + ;; left and the start of the one on the right are the same frame, so + ;; a gesture on that frame alters the extent of one and then the + ;; other -- `span/roll`, which is literally `resize-out` then + ;; `resize-in`. + ;; + ;; It used to pick between three things by where in the handle the + ;; press landed: trim-left, roll, trim-right, in thirds of twelve + ;; pixels. Four pixels is not an aim, so a gesture that meant to roll + ;; trimmed one side instead and the handle looked like it chose for + ;; itself. Trimming one side alone leaves a GAP, which is a different + ;; intention and has its own handle on an edge that is not shared. [:span.tl-junction - {:title "Left: trim left · center: roll cut · right: trim right" + {:title "Drag the cut · the clip before it ends here and the one after it starts here" :on-pointer-down (fn [^js e] - (let [box (.getBoundingClientRect (.-currentTarget e)) - x (/ (- (.-clientX e) (.-left box)) (max 1 (.-width box))) - left-path (nth (:select prev) 3) - right-path (nth select 3)] - (cond - (< x 0.34) (begin! e left-path :out (:select prev) nil nil) - (> x 0.66) (begin! e right-path :in select nil nil) - :else (begin! e right-path :roll select left-path nil))))}] + (begin! e (nth select 3) :roll select (nth (:select prev) 3) nil))}] [:span.tl-edge.in {:title "Drag start" :on-pointer-down #(begin! % (nth select 3) :in select nil nil)}])) (when select @@ -927,8 +995,13 @@ renaming (r/atom nil) draft (r/atom "")] (let [clip @(rf/subscribe [::render/clip]) - frames (max 1 (or @(rf/subscribe [::render/frames]) 1)) - frame @(rf/subscribe [::playback/frame]) + ;; THE RULER IS THE OPEN SYMBOL'S OWN FRAME SPACE. Every span, key and + ;; cut drawn here is stored in that space, so drawing them against the + ;; output grid put almost none of them on a mark and left every gesture + ;; converting and rounding — see `docs/one-grid-plan.md`. The transport + ;; keeps the output numbers; this pane does not use them. + frames (max 1 (or @(rf/subscribe [::render/open-frames]) 1)) + frame @(rf/subscribe [::render/open-frame]) store @(rf/subscribe [::render/store]) selection @(rf/subscribe [::sub/selection]) target @(rf/subscribe [::sub/target]) @@ -941,18 +1014,26 @@ ;; every placement of a face, because the switch is the face's. tracing {:faces (set (trace/traceable-faces clip open)) :on (set (:faces @(rf/subscribe [::render/tracing])))} - ;; Where a drag out of the pool would land, as a row of its own at the - ;; top of its section: its own length, starting on the frame it would - ;; start on. The stage's drop shows it too, at the playhead. + ;; WHERE THE DROP WILL LAND IS WHERE THE PREVIEW GOES, and what is being + ;; carried does not come into it. A row under the pointer takes the + ;; preview as a block in that row -- the same row `ui/drop-destination` + ;; will resolve on release -- and with no row under the pointer it is a + ;; row of its own at the top of the section it will appear in. + ;; + ;; A SOUND USED TO BE THE EXCEPTION: aimed at a lane it drew no block + ;; there and a ghost row down in the audio section instead, so dragging + ;; a sound onto a lane looked exactly like a lane refusing it -- while + ;; the drop itself worked and put the sound in the lane. One rule + ;; resolves the drop, so one rule previews it. drop-lane (:target drop) + drop-span (when drop [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))]) ghost (when (and drop (nil? drop-lane)) {:path [::drop] :depth 0 :kind :ghost :label (str "+ " (:label drop)) - :span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))] - :keys []}) - lane-ghost (when (and drop drop-lane (not (:sound? drop))) + :span drop-span :keys []}) + lane-ghost (when (and drop drop-lane) {:id ::drop :label (str "+ " (:label drop)) :ghost? true - :span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))]}) + :span drop-span}) chosen (when (= :node (first selection)) (nth selection 3)) picture (cond->> (cond->> (rows clip open expanded chosen) lane-ghost @@ -971,7 +1052,14 @@ (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)))] + step (* 10 (js/Math.ceil (/ frames 100))) + ;; 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 + ;; first shows it. Several marks can share one output frame when the + ;; symbol was authored on a finer grid; that is what playing it at this + ;; project's rate means, and the playhead lands where it will really be. + seek-to (fn [^js e n] (clip/first-output-frame clip open (frame-at e n)))] [:section.pane.time [transport] [:div.tl-body @@ -1023,7 +1111,7 @@ :style {"--tick" (str (* 100 (/ 5 frames)) "%")}} [:div.tl-ruler {:on-pointer-down (fn [^js e] - (rf/dispatch [::pb/seek (frame-at e frames)]) + (rf/dispatch [::pb/seek (seek-to e frames)]) (reset! scrubbing true) ;; Capture is what keeps a drag scrubbing once it ;; leaves the ruler, and it is an ENHANCEMENT: it @@ -1039,7 +1127,7 @@ (catch :default _ nil))) :on-pointer-move (fn [^js e] (when @scrubbing - (rf/dispatch [::pb/seek (frame-at e frames)]))) + (rf/dispatch [::pb/seek (seek-to e frames)]))) :on-pointer-up (fn [_] (reset! scrubbing false)) :on-pointer-cancel (fn [_] (reset! scrubbing false))} (doall @@ -1051,7 +1139,8 @@ (with-meta (if (= :section (:kind row)) [:div.tl-track.tl-section] [track-cell row frames sliding hint - {:clip clip :store store :open open :frame frame}]) + {:clip clip :store store :open open :frame frame + :selection selection}]) {:key (str (:path row))}))) [:div.tl-empty "nothing in this symbol"]) [:div.tl-playhead {:style {:left (at% frame frames)}}]]] diff --git a/frontend/test/arthur/domain/cadence_test.cljs b/frontend/test/arthur/domain/cadence_test.cljs index 1da58ad..72083d5 100644 --- a/frontend/test/arthur/domain/cadence_test.cljs +++ b/frontend/test/arthur/domain/cadence_test.cljs @@ -36,8 +36,28 @@ (is (= footage (clip/set-fps doc 30))) (is (= doc (leaf/clip "test" (leaf/leaves "test" doc)))) (is (= [1 3 6 8 11 13] (mapv #(:size (first (draw %))) (range 6)))) - (is (= 7 (:frame (nest/inside doc store :main [:mark] 3)))) - (is (= [0 24] (:span (first (timeline/rows doc :main #{}))))))) + ;; `nest/inside` and the timeline's rows are in the SYMBOL's own frames -- + ;; see `docs/one-grid-plan.md`. Frame 7 of :main is frame 7 of :main; the + ;; output frame that shows it is 3, and `shown-frame` is the one function + ;; that crosses between the two. + (is (= 7 (:frame (nest/inside doc store :main [:mark] 7)))) + (is (= 7 (clip/shown-frame doc :main 3))) + (is (= 3 (clip/first-output-frame doc :main 7))) + (is (= [0 60] (:span (first (timeline/rows doc :main #{}))))))) + +(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 + ;; after the mark, never before it, and exactly on it whenever an output frame + ;; shows it at all. + (doseq [project [8 12 24 30 60] native [12 24 30 60]] + (let [doc (-> footage (assoc :fps project) (assoc-in [:symbols :main :fps] native))] + (doseq [n (range 40)] + (let [f (clip/first-output-frame doc :main n)] + (is (integer? f)) + (is (>= (clip/shown-frame doc :main f) n) + (str n " at " project "/" native)) + (is (or (zero? f) (< (clip/shown-frame doc :main (dec f)) n)))))))) (deftest imported-footage-uses-selection-for-picture-and-real-speed-for-audio (let [{doc :clip sid :sid} (bring/take (clip/set-fps (clip/blank) 12) diff --git a/frontend/test/arthur/domain/nest_test.cljs b/frontend/test/arthur/domain/nest_test.cljs index 4dd04d9..d224a4f 100644 --- a/frontend/test/arthur/domain/nest_test.cljs +++ b/frontend/test/arthur/domain/nest_test.cljs @@ -281,3 +281,78 @@ (get-in [:symbols :outer :nodes u]))] (is (empty? (node/problems v))) (is (= [0 36] (node/placed-span v)) "90 frames at 30 are 36 at 12"))))) + +;; --------------------------------------------------------------------------- +;; the ruler IS the symbol's frames + +(defn- on-twos + "A 12fps project holding a 30fps lane, which is what changing project fps to + 12 leaves behind: `docs/time.md` records each symbol's own rate so its timing + stays put. Two one-frame held clips in it, at 5 and at 19." + [] + (let [held (fn [id at] {:id id :kind :instance :z (str "a-" id) :span [0 1] + ;; As `span/held` writes it: no `:mode`, because + ;; `node/time-of` reads an absent one as `:map`. + :time {:at at :rate 1} + :source {:symbol :draw} :playback {:in 0 :speed 0 :end :stop}})] + (-> (clip/blank) + (assoc :fps 12) + (assoc-in [:symbols :draw] {:id :draw :fps 30 :frames 1 :nodes {}}) + (assoc-in [:symbols :lane] {:id :lane :fps 30 :frames 60 :display :lane + :nodes {:a (held :a 5) :b (held :b 19)}})))) + +(deftest a-drag-in-the-open-symbol-is-frame-for-frame + ;; `df` counts the OPEN SYMBOL's frames, because that is what the ruler is + ;; drawn in -- see `docs/one-grid-plan.md`. So one frame of the gesture is one + ;; frame of the document, the project's output rate is not in the arithmetic at + ;; all, and nothing can land between two frames. + (doseq [project [8 12 24 30 60]] + (let [c (assoc (on-twos) :fps project) + at #(get-in % [:symbols :lane :nodes :b :time :at]) + span #(node/placed-span (get-in % [:symbols :lane :nodes :b]))] + (is (= 20 (at (:clip (nest/slide c :lane [:b] 1)))) (str "at " project "fps")) + (is (= 18 (at (:clip (nest/slide c :lane [:b] -1))))) + (is (= 24 (at (:clip (nest/slide c :lane [:b] 5))))) + (is (= [20 21] (span (:clip (nest/slide c :lane [:b] 1))))) + (testing "an edge drag too" + (let [grown (nest/resize-out c :lane [:b] 1 false)] + (is (nil? (:refused grown)) (:refused grown)) + (is (= [19 21] (node/placed-span (get-in (:clip grown) [:symbols :lane :nodes :b])))) + (is (= [18 20] (node/placed-span + (get-in (:clip (nest/resize-in c :lane [:b] -1)) + [:symbols :lane :nodes :b]))))))))) + +(deftest a-drag-through-a-retimed-instance-still-lands-on-a-whole-frame + ;; The one case where a ruler frame is not a frame of the space being edited: + ;; a row reached THROUGH an instance somebody deliberately retimed. At double + ;; speed one frame of the ruler is two inside, and at half speed it is half of + ;; one -- which has to round, because a frame number cannot fall between two + ;; frames. That rounding is `nest/dragged` and it is the whole of its job. + (let [inner (fn [rate] + (-> (on-twos) + (assoc-in [:symbols :outer] + {:id :outer :fps 30 :frames 60 + :nodes {:ins {:id :ins :kind :instance :z "a" :span [0 60] + :time {:mode :map :at 0 :rate rate} + :source {:symbol :lane} + :playback {:in 0 :speed 1 :end :stop}}}}))) + at (fn [rate df] + (get-in (:clip (nest/slide (inner rate) :outer [:ins :b] df)) + [:symbols :lane :nodes :b :time :at]))] + (is (= 21 (at 2 1)) "double speed inside: one frame of the ruler is two") + (is (= 23 (at 2 2))) + (is (= 20 (at 0.5 2)) "half speed: two frames of the ruler are one inside") + (is (= 20 (at 0.5 1)) "and one lands on the nearer whole frame, half upwards") + (is (every? integer? (map #(at 0.5 %) (range -4 5)))))) + +(deftest sliding-a-held-clip-moves-it-rather-than-forgetting-where-it-was + ;; `span/held` writes `{:at f :rate 1}` with no `:mode`, and the slide used to + ;; read that as "no time map" and replace the whole thing — so the first drag + ;; of a freshly drawn clip threw its `:at` away and jumped it to the head of + ;; the lane. Same grid both sides here, so the only question is the mode. + (let [c (assoc (on-twos) :fps 30) + slid (:clip (nest/slide c :lane [:b] 3))] + (is (= 22 (get-in slid [:symbols :lane :nodes :b :time :at]))) + (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"))) diff --git a/frontend/test/arthur/events/lane_test.cljs b/frontend/test/arthur/events/lane_test.cljs index 9d5601a..8aade43 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -115,3 +115,73 @@ lane (first (filter :lane? (timeline/rows doc :main #{} nil)))] (is (= [:vo] (mapv :id (:cels lane)))) (is (empty? (timeline/sound-rows doc :main #{}))))) + +(deftest aiming-the-open-lanes-own-row-is-a-selection-and-not-a-crash + ;; `rows` names the open symbol's own lane row `[:node sid nil []]`, and + ;; `selected` used to open the rows above a selection with `(pop path)` — + ;; which THROWS on `[]`, aborting the whole event. Nothing was selected, + ;; nothing was aimed, and the next polygon went wherever the stale target + ;; still pointed, which is most of what made aiming a lane feel random. + (let [doc (fixture/document) + id (store/install! {:clip doc :store {}} "aim-the-open-lane") + db {:clip/current id :paint/revision 0 + :ui {:open :main :target {:sid :main :id :a :path [:a]}} + :playback {:frame 0}} + lane (first (filter :lane? (timeline/rows doc :main #{}))) + after (ui/aimed db (:select lane))] + (is (= [:node :main nil []] (:select lane))) + (is (= [:node :main nil []] (get-in after [:ui :selection]))) + (is (nil? (get-in after [:ui :target])) + "the open symbol IS the place, so aiming its own row clears the target") + (is (= [] (ui/where-new-goes doc after))) + (is (empty? (get-in after [:ui :expanded])) + "and there are no rows above the top to open"))) + +(deftest finishing-a-polygon-opens-no-rows + ;; Expansion is the twist triangle's business. Finishing a shape used to open + ;; every row down to it, which inside a lane meant tearing its one row into a + ;; portal, that clip's channels and every shape already in the drawing. + (let [doc (fixture/document) + id (store/install! {:clip doc :store {}} "finish-opens-nothing") + db {:clip/current id :paint/revision 0 + :ui {:open :main :tool :polygon + :target {:sid :main :id :a :path [:a]} + :draft [10 10 40 10 40 40]} + :playback {:frame 1}}] + (reset! rf-db/app-db db) + (rf/dispatch-sync [::ui/finish-polygon]) + (let [after @rf-db/app-db + [kind sid shape-id path] (get-in after [:ui :selection])] + (is (= :node kind)) + (is (= :drawing-a sid) "the shape went into the drawing the aimed clip places") + (is (= [:a shape-id] path)) + (is (empty? (get-in after [:ui :expanded]))) + (is (nil? (get-in after [:ui :tool])))))) + +(deftest a-clip-made-for-a-drawing-is-selected-in-the-symbol-it-lives-in + ;; The clip `beginning-polygon` materializes lives in the symbol the LANE + ;; draws. Naming the open one instead left a selection that looked up to + ;; nothing, so the inspector, the breadcrumb and every span command went blank + ;; on a drawing that had just been created. + (let [doc (-> (fixture/document) + ;; The last of the lane's three clips taken out, so frames 8 + ;; to 12 are a gap and drawing there makes the held clip that + ;; was missing rather than landing in one that is there. + (update-in [:symbols :main :nodes] dissoc :insert) + (assoc-in [:symbols :shot] + {:id :shot :fps 24 :frames 12 + :nodes {:girl {:id :girl :kind :instance :z "b" :span [0 12] + :time {:mode :map :at 0 :rate 1} + :source {:symbol :main} + :playback {:in 0 :speed 1 :end :stop}}}})) + id (store/install! {:clip doc :store {}} "clip-selected-where-it-lives") + db {:clip/current id :paint/revision 0 + :ui {:open :shot :target {:sid :shot :id :girl :path [:girl]}} + :playback {:frame 9}} + after (ui/beginning-polygon db) + [_ sid clip-id path] (get-in after [:ui :selection]) + saved (:clip (store/entry id))] + (is (= :main sid) "the lane's symbol, not the open one") + (is (= [:girl clip-id] path)) + (is (some? (get-in saved [:symbols :main :nodes clip-id])) + "and that is where the node actually is"))) diff --git a/static/arthur/app.css b/static/arthur/app.css index c6a66ca..80a3afa 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -56,6 +56,18 @@ --aim: #8d4bd6; --aim-stage: #c79bf2; + /* A THIRD, and the last. SELECTED IN THE TIMELINE had been --sel-bg, which is + #cfe0f5 against spans drawn in #dfe8f4: two pale blues a hair apart, in the + one place where which row and which clip is selected decides what the pane + is showing — an expanded lane opens exactly the selected clip. The fix is + not a stronger blue, which stays in the family the spans already own, but a + different hue entirely. Ochre: warm against every cool surface in the pane, + clear of --aim's violet, --live's green and the playhead's red, and dark + enough at full strength to carry white type on a 21px block. */ + --pick: #a85c00; + --pick-bg: #ffd9a0; + --pick-fg: #4a2800; + /* Auto-key is a recording state, distinct from selection and aiming. */ --live: #239447; --live-bg: #e2f5e7; @@ -975,6 +987,14 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } as tall as its rows instead, and at least as tall as the body. */ align-items: flex-start; overflow: auto; + /* THE GUTTER IS ALWAYS THERE, whether or not there is a scrollbar in it. Every + mark in this pane is a percentage of the tracks column, and the column is + what is left of the body after the labels -- so a vertical scrollbar + appearing took 15px out of the width and moved the ruler, the frame grid, + every clip and the playhead sideways, mid-gesture. Expanding a lane's portal + adds seven rows at once, which is exactly when a person is looking at the + frame they are aiming for. */ + scrollbar-gutter: stable; } .tl-labels, @@ -1039,11 +1059,38 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } overflow: hidden; } -.tl-label.on { background: var(--sel-bg); } +/* Selected, in the timeline's own colour. The bar down the left edge is the + half that survives at a glance: a row 21px tall scrolled past in a column of + twenty reads its left edge before it reads its fill. */ +.tl-label.on { + background: var(--pick-bg); + box-shadow: inset 3px 0 0 var(--pick); + color: var(--pick-fg); +} +.tl-label.on .kind { color: var(--pick); } .tl-label.aimed { box-shadow: inset 0 0 0 2px var(--aim); } +/* Both at once, and they must still be two readable facts: the aim's ring sits + inside the pick's edge rather than replacing it. */ +.tl-label.on.aimed { box-shadow: inset 3px 0 0 var(--pick), inset 0 0 0 2px var(--aim); } .tl-label:hover:not(.on) { background: #fff; } .tl-label .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .tl-label .kind { color: var(--dim); } + +/* A lane's row: the strip glyph, a faint warm ground under the name, and a + track behind its blocks — enough that a lane is told from the instance rows + around it while scanning, and not so much that it shouts. */ +.tl-ico { + flex: 0 0 12px; + line-height: 0; + color: var(--dim); +} +.tl-ico > svg { display: block; width: 11px; height: 11px; } +.tl-label.lane { background: #edeae2; } +.tl-label.lane .name { font-weight: 600; } +.tl-label.lane.on { background: var(--pick-bg); } +.tl-label.lane.on .tl-ico { color: var(--pick); } +.tl-label.lane:hover:not(.on) { background: #f8f6f0; } +.tl-track.lane { background: rgba(0, 0, 0, 0.035); } .tl-name-input { min-width: 0; flex: 1; font: inherit; } .tl-rename { margin-left: auto; padding: 0 3px; border: 0; background: none; color: var(--dim); } .tl-rename:hover { color: var(--fg); } @@ -1138,6 +1185,22 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-label.drop-front { box-shadow: inset 0 2px 0 var(--sel); } .tl-label.drop-back { box-shadow: inset 0 -2px 0 var(--sel); } +/* Selected: the clip block and the plain bar alike, in the pick colour. A clip + block had no selected state at all, which is why the portal under an expanded + lane looked like it opened at random. */ +.tl-cel.on { + background: var(--pick); + border-color: var(--pick); + color: #fff; + font-weight: 600; +} +.tl-cel.on:hover:not(:disabled) { background: var(--pick); filter: brightness(1.12); } +.tl-span.on { background: var(--pick-bg); border-color: var(--pick); } +.tl-span.on.dense { + background: repeating-linear-gradient( + -45deg, var(--pick-bg) 0 3px, #f0bf77 3px 6px); +} + /* A node's bar slides it along its symbol's time. */ .tl-span.movable { cursor: ew-resize; touch-action: none; } .tl-span.movable.sliding { border-color: var(--sel); } @@ -1155,7 +1218,10 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-junction { position: absolute; top: -1px; - left: 0; + /* Centred on the CUT, which is the clips' shared border-box edge. A clip block + is a button with a 1px border, so `left: 0` is one pixel inside it and the + handle sat a pixel to the right of the line it drags. */ + left: -1px; bottom: -1px; width: 12px; transform: translateX(-50%); @@ -1163,7 +1229,18 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } touch-action: none; z-index: 3; } -.tl-cel-label { display: block; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } +/* `nowrap` is load-bearing. A block narrower than its name wrapped it at the + hyphen — "symbol-" then "8" — and the button is `overflow: visible` so that + second line spilled out below the track as a row of loose digits under the + lane, which read as corrupt timing rather than as a long name in a short + block. One line, clipped. */ +.tl-cel-label { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + pointer-events: none; +} .tl-span.ghost { background: transparent; border: 1px dashed var(--sel);