diff --git a/docs/one-grid-plan.md b/docs/one-grid-plan.md deleted file mode 100644 index 90414ce..0000000 --- a/docs/one-grid-plan.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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/bring.cljs b/frontend/src/arthur/domain/bring.cljs index 166ca51..6527a7d 100644 --- a/frontend/src/arthur/domain/bring.cljs +++ b/frontend/src/arthur/domain/bring.cljs @@ -64,16 +64,9 @@ `frozen` is what `flow/freeze/clip` makes: a `:main` that places one symbol per tracked face. `:main` becomes the named symbol — it is what holds the faces in - stage pixels, so it is the thing worth placing. Each generated face symbol gets - an automatic association with the take's SOUND, source frames `range` of - footage `footage-id`. Thus the take is heard through the faces it places, and - a face subsequently placed by itself still brings its sound. This is the same - shape a future manual symbol/sound association can write; dropping a face is - not a special operation. - - A multi-face take consequently reaches the same recording through several - symbols. `nest/audio-tracks` collapses simultaneous copies carrying the same - `:media-link`; placing those faces at different times still schedules each one. + stage pixels, so it is the thing worth placing — and it gets the take's SOUND + as an audio node of its own, source frames `range` of footage `footage-id`, so + wherever the symbol is placed it is heard. The tracking identities, and the analysis they were measured by, come along only when `clip` has no analysis of its own and no face had to be renamed. A @@ -83,27 +76,20 @@ [clip frozen label footage-id range] (let [{c :clip ids :ids} (symbols clip frozen [:main] {:main (symbol-id label)}) sid (ids :main) - faces (mapv ids (sort-by str (keys (:subjects frozen)))) - ;; Older/plain imported clips have no subject table. They are still a - ;; take, so their wrapper remains the only honest owner of the sound. - sound-hosts (if (seq faces) faces [sid]) - sound {:id :sound :name "sound" :kind :audio :parent nil - :z "z-sound" :source {:footage footage-id} - ;; All automatic copies name one recording. The audio walk - ;; uses this identity only to avoid mixing that recording once - ;; per detected face when the complete take is played. - :media-link [:footage footage-id range] - ;; Source frame `start` plays on the symbol's 0. - :span range - :time {:mode :map :at (- (first range)) :rate 1}} tracked? (and (nil? (:analysis clip)) (every? #(= % (ids %)) (keys (:subjects frozen))))] {:sid sid :tracked? tracked? - :clip (cond-> (-> (reduce (fn [document face] - (assoc-in document [:symbols face :nodes :sound] sound)) - c sound-hosts) - (assoc-in [:symbols sid :name] (str label))) + :clip (cond-> (-> c + (assoc-in [:symbols sid :name] (str label)) + (assoc-in [:symbols sid :nodes :sound] + {:id :sound :name "sound" :kind :audio :parent nil + :z "z-sound" :source {:footage footage-id} + ;; Source frame `start` plays on the symbol's 0. + :span range + :time {:mode :map + :at (- (first range)) + :rate 1}})) tracked? (-> (assoc :analysis (:analysis frozen)) (update :subjects merge (:subjects frozen)) (update :features merge (:features frozen)) diff --git a/frontend/src/arthur/domain/cadence.cljs b/frontend/src/arthur/domain/cadence.cljs index 28b60cb..736d7d5 100644 --- a/frontend/src/arthur/domain/cadence.cljs +++ b/frontend/src/arthur/domain/cadence.cljs @@ -13,13 +13,3 @@ "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 5f5f807..fee987c 100644 --- a/frontend/src/arthur/domain/clip.cljs +++ b/frontend/src/arthur/domain/clip.cljs @@ -84,27 +84,6 @@ (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 8558ae8..9936314 100644 --- a/frontend/src/arthur/domain/nest.cljs +++ b/frontend/src/arthur/domain/nest.cljs @@ -39,20 +39,13 @@ r)) (defn inside - "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 + "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 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 @@ -91,8 +84,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) - :matrix (node/mat) :time node/same-time} + {:sid sid :frame (js/Math.floor (* f (:rate (clip/grid-time clip sid)))) + :matrix (node/mat) :time (clip/grid-time clip sid)} path)) (defn placement @@ -134,12 +127,7 @@ (defn audio-tracks "Flatten audible source intervals through cel and parent clocks. A held visual source is silent. Every returned track carries a source offset, - an output interval, and automation mapped into the open symbol's time. - - Automatically associated face audio can be reached more than once in a - multi-face take. Equal `:media-link`s at the same source and output interval - are one recording, not a louder mix; at different placements they remain - separate scheduled clips." + an output interval, and automation mapped into the open symbol's time." [clip sid] (letfn [(to-local [m f] (* (:rate m) (- f (:at m)))) (to-outer [m f] (+ (:at m) (/ f (:rate m)))) @@ -205,17 +193,7 @@ periods)))) nil)))) (sort-by (comp str key) nodes))))] - (let [tracks (walk sid (clip/grid-time clip sid) - [0 (clip/output-frames clip sid)] [] #{})] - (second - (reduce (fn [[seen out] track] - (let [link (:media-link track) - k (when link [link (:source track) (:span track) - (select-keys (:time track) [:at :rate :offset])])] - (if (and k (contains? seen k)) - [seen out] - [(cond-> seen k (conj k)) (conj out track)]))) - [#{} []] tracks))))) + (vec (walk sid (clip/grid-time clip sid) [0 (clip/output-frames clip sid)] [] #{})))) (defn- retime "Node `n` with its own time map replaced by `m`, and nothing else touched: its @@ -375,28 +353,7 @@ sids path)] {:sid (last sids) :time (when (every? some? 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))))) + (reduce node/then-time (clip/grid-time clip sid) maps))})) (defn slide "Move the node at row path `path` along its symbol's time by `df` frames of @@ -413,23 +370,10 @@ (nil? (get nodes id)) {:refused "nothing to move"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (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. + (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)))) shift (fn [n] - (if (and (:time n) (node/mapped-time? n)) + (if (= :map (get-in n [:time :mode])) (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))) @@ -441,20 +385,11 @@ (if (and (= :audio (:kind n)) (= id (:linked-to n))) (assoc ns audio-id (shift n)) ns)) - 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))))) + 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)}))))) (defn resize-out "Move the right edge of the node at `path` by `df` frames of `open`. @@ -472,8 +407,10 @@ (nil? n) {:refused "nothing to resize"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (span/resize-out clip sid id (dragged here nodes id (second (node/placed-span n)) df) - {:ripple? ripple? :extent :grow-symbol})))) + (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}))))) (defn resize-in "Move the left edge of the node at `path` by `df` frames of `open`." @@ -487,7 +424,10 @@ (nil? n) {:refused "nothing to resize"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (span/resize-in clip sid id (dragged here nodes id (first (node/placed-span n)) df))))) + (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))))) (defn roll "Move the shared boundary at `right-path` and the adjacent `left-path`." @@ -503,8 +443,10 @@ {:refused "a rolling edit needs adjacent clips in one sequence"} (nil? (:time here)) {:refused "a looping instance is in the way"} :else - (span/roll clip sid left-id right-id - (dragged here nodes right-id (first (node/placed-span right)) df))))) + (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))))) (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 e23715f..3bb5be5 100644 --- a/frontend/src/arthur/domain/node.cljs +++ b/frontend/src/arthur/domain/node.cljs @@ -161,12 +161,6 @@ [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 @@ -185,20 +179,6 @@ {: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 ceefbb0..14516ce 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -32,22 +32,6 @@ [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]] @@ -64,14 +48,7 @@ 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. - - 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?`." + opening the placement it is heard through would bury it." [db selection] (let [[kind sid id path] selection sound? (= :audio (get-in (store/entry (:clip/current db)) @@ -79,7 +56,7 @@ (cond-> (-> db (assoc-in [:ui :selection] selection) (update :ui dissoc :points :retry)) - (and (= :node kind) (seq path) (not sound?)) + (and (= :node kind) path (not sound?)) (update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] (pop path))))))) (defn target-of @@ -196,18 +173,12 @@ [db sid result retry] (let [{clip :clip st :store} (store/entry (:clip/current db)) path (nth (get-in db [:ui :selection]) 3 nil) - open (get-in db [:ui :open]) - {:keys [at rate]} (:time (nest/inside clip st open + {:keys [at rate]} (:time (nest/inside clip st (get-in db [:ui :open]) (if (seq path) (pop path) []) - (editing-frame db clip)))] + (get-in db [:playback :frame])))] (cond-> {:db (apply-command db sid result retry)} (and (:clip result) (: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)))])))) + (assoc :dispatch [::playback/seek (+ at (/ (:frame result) rate))])))) (defn selection-frame "The playhead as a frame of the symbol that owns `selection`. @@ -261,7 +232,7 @@ selection (get-in db [:ui :selection]) [_ sid] selection at (selection-frame clip st (get-in db [:ui :open]) selection - (editing-frame db clip)) + (get-in db [:playback :frame])) result (if (integer? at) (span/append-drawing clip sid (random-uuid) (clip/fresh-id clip) {:at at :extent (or extent :keep)}) @@ -282,7 +253,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 - (editing-frame db clip))] + (get-in db [:playback :frame]))] (span/host-frame clip sid id owner-frame))})) (def ^:private no-frame @@ -304,7 +275,7 @@ selection (get-in db [:ui :selection]) [_ sid] selection at (selection-frame clip st (get-in db [:ui :open]) selection - (editing-frame db clip)) + (get-in db [:playback :frame])) result (if (integer? at) (span/overwrite-drawing clip sid (random-uuid) (clip/fresh-id clip) at {:extent (or extent :keep) @@ -495,15 +466,13 @@ (cond (not (integer? frame)) {:refused "the playhead is not on one frame of this symbol"} - exposed {:clip clip :sid sid :path (conj (vec path) (:id exposed))} + exposed {:clip clip :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) :sid sid :path (conj (vec path) cel-id)}))))) + (if (:refused made) made {:clip (:clip made) :path (conj (vec path) cel-id)}))))) (defn polygon-landing "Choose the document and row path a finished polygon is drawn into. @@ -514,10 +483,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 (editing-frame db clip))] + (let [into (aimed-symbol clip st db (get-in db [:playback :frame]))] (if (and (:sid into) (symbol/lane? (clip/symbol clip (:sid into)))) (assoc (into-the-sequence clip db into) :lane? true) - {:clip clip :sid (:sid into) :path (:path into) :lane? false}))) + {:clip clip :path (:path into) :lane? false}))) (defn beginning-polygon "Enter polygon mode, first materializing a drawing at the playhead when the @@ -528,6 +497,7 @@ 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))) @@ -538,14 +508,8 @@ 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 (:sid landing) + [:node open (peek (:path landing)) (:path landing)])) :else db)] @@ -570,7 +534,7 @@ down (:path landing) {:keys [sid frame pts]} (when-not (:refused landing) - (nest/drawn-inside source st open down (editing-frame db source) draft))] + (nest/drawn-inside source st open down (get-in db [:playback :frame]) draft))] (cond (< (count draft) 6) db (:refused landing) (update db :project merge {:status (:refused landing)}) @@ -586,19 +550,13 @@ ;; 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)]}))))))) + :selection [:node sid id (conj down id)]}) + (update-in [:ui :expanded] (fnil into #{}) + (rest (reductions conj [] down))))))))) (rf/reg-event-db ::set-knob @@ -623,7 +581,7 @@ [db where lane?] (let [{document :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) - frame (editing-frame db document) + frame (get-in db [:playback :frame]) into (if (= :top where) (assoc (nest/inside document st open [] frame) :path []) (aimed-symbol document st db frame)) @@ -699,10 +657,6 @@ 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." @@ -763,14 +717,11 @@ 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) uuid)] + (* (:at where) + (:rate (clip/grid-time (:clip where) sid))) + uuid)] (landed db where uuid (span/adopt seeded sid uuid (:at where) {:extent :grow-symbol :remainder-id (random-uuid)}))))))) @@ -806,7 +757,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 - (editing-frame db clip))] + (get-in db [:playback :frame]))] (if-let [why (:refused r)] (refused db why) (-> db @@ -874,20 +825,14 @@ (update db :ui dissoc :gesture))))) (defn record-auto-frame - "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." + "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." [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 - (clip/shown-frame document open f))] + (if-let [{:keys [sid id frame]} (nest/placement document st open path f)] (assoc-in db [:ui :gesture] (-> g (assoc :sid sid :id id :frame frame) @@ -932,7 +877,7 @@ (fn [db [_ from to front?]] (let [{clip :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) - f (editing-frame db clip) + f (get-in db [:playback :frame]) host (pop to) moved (if (= host (pop from)) {:clip clip :id (peek from)} @@ -952,7 +897,7 @@ (fn [db [_ froms]] (let [{clip :clip st :store} (store/entry (:clip/current db)) open (get-in db [:ui :open]) - f (editing-frame db clip) + f (get-in db [:playback :frame]) 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 b4df292..fabeb39 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -80,31 +80,8 @@ ::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 65fd08c..38a4c33 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] - :<- [::render/open-frame] + :<- [::playback/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] - :<- [::render/open-frame] + :<- [::playback/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] - :<- [::render/open-frame] + :<- [::playback/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 d83239f..c91f827 100644 --- a/frontend/src/arthur/ui/icon.cljs +++ b/frontend/src/arthur/ui/icon.cljs @@ -16,13 +16,11 @@ 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, 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." + 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." (:require [clojure.string :as str])) ;; A glyph is its paths on a 12x12 grid. Solid shapes are filled; the two @@ -42,12 +40,7 @@ :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"]} - ;; 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"]}}) + :stroke ["M7.9 4.4 10.7 7.6" "M10.7 4.4 7.9 7.6"]}}) (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 b01f246..6a58b05 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 (node/mapped-time? n) (str (get-in n [:time :at] 0)))] + "at" (when (= :map (get-in n [:time :mode])) (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 8c419cf..626bc35 100644 --- a/frontend/src/arthur/ui/stage.cljs +++ b/frontend/src/arthur/ui/stage.cljs @@ -270,10 +270,7 @@ [_ _ _ selected] @(rf/subscribe [::sub/selection]) clip-id @(rf/subscribe [::render/clip-id]) ctx {:clip-id clip-id - ;; 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]) + :open @(rf/subscribe [::render/open]) :f @(rf/subscribe [::playback/frame]) :w w :h h} points? @(rf/subscribe [::sub/points]) [sid id geom active editable? frame matrix] (when points? (editing)) @@ -355,9 +352,7 @@ ;; reallocates the backing store — so this re-rendering costs nothing per frame. (let [w @(rf/subscribe [::playback/width]) h @(rf/subscribe [::playback/height]) - ;; 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]) + frame @(rf/subscribe [::playback/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 8ce0833..1b6e8d9 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -314,11 +314,7 @@ sound-lane? (mapv #(assoc % :sound? true)))))) ordered))))] (if (get-in clip [:symbols 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) + (walk sid [] 0 #(/ % (:rate (clip/grid-time clip sid)))) []))))) (defn sound-rows @@ -332,20 +328,12 @@ 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)) - ;; 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)]))] + (let [lane? (symbol/lane? (clip/symbol clip sid))] (vec (mapcat (fn [[path tracks]] (let [n (first tracks) - span (own-span (node/placed-span n)) + span (node/placed-span n) select [:node (:owner n) (:id n) path] open? (contains? expanded path) via (when (< 1 (count path)) (str (first path))) @@ -353,15 +341,14 @@ :kind :node :node-kind :audio :via via :slides (if via (subvec path 0 1) path) :select select :expandable? true :expanded? open? :span span - :keys (mapv own (distinct (mapcat keyed-frames (vals (:channels n)))))}] + :keys (vec (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 (own-span (node/placed-span track)) - :select select}) + :span (node/placed-span track) :select select}) (range) tracks))) - (when open? (channel-rows n path 1 own span))))) + (when open? (channel-rows n path 1 identity span))))) (sort-by (comp str key) (group-by :path (remove #(and lane? (= 1 (count (:path %)))) @@ -376,28 +363,17 @@ (defn- at% [f frames] (str (* 100 (/ (+ f 0.5) (max 1 frames))) "%")) (defn- edge% [f frames] (str (* 100 (/ f (max 1 frames))) "%")) -(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) +(defn- frame-at + "Which frame the pointer is over." + [^js event frames] + (let [box (.getBoundingClientRect (.-currentTarget event)) 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- 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- lane-under "The lane track geometrically under a captured pointer, and its selection." @@ -447,11 +423,8 @@ ;; 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 -- 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)) + ;; where the playhead is somewhere it means something. + owner-frame (ui/selection-frame clip st open selection 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. @@ -578,7 +551,6 @@ [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 @@ -643,12 +615,6 @@ (.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" @@ -662,9 +628,7 @@ "Escape" (do (.preventDefault e) (reset! renaming nil)) nil))}] [:span.name label]) - (cond - lane? [:span.kind "·lane"] - node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))]) + (when node? [:span.kind (if via (str "· in " via) (str "·" (name node-kind)))]) (when lane? [:button.tl-rename {:title "rename lane (F2)" @@ -696,22 +660,16 @@ "×"])])) (defn- track-cell - "`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." + "`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." [{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped?]} - frames sliding hint {:keys [clip store open frame selection]}] + frames sliding hint {:keys [clip store open frame]}] (let [active-row (:row @sliding) slide (fn [^js e] - (let [{from :row pressed :from} @sliding] + (let [{from :row x0 :x width :width} @sliding] (when (= path from) - ;; 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) + (let [df (js/Math.round (/ (* frames (- (.-clientX e) x0)) (max 1 width))) drag (:drag @sliding) ;; TWO INTENTIONS, SAID BY A MODIFIER. An ordinary ;; body drag moves a clip in TIME, within its lane @@ -736,7 +694,7 @@ [target-el target] (when (and drag (not shift?)) (lane-under e)) landing? (some? target) target-frame (when landing? - (max 0 (- (frame-under e frames target-el) + (max 0 (- (frame-at-element e frames target-el) (:grab drag))))] (when (and drag hint) (reset! hint @@ -808,22 +766,17 @@ :on-click actual-select :drag (when drag (assoc drag - :grab (- (frame-under e frames track) + :grab (- (frame-at-element e frames track) (:in drag)))) :ripple? (and (= :out gesture-kind) (.-shiftKey e)) - ;; 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}) + :x (.-clientX e) :df 0 + :width (.-width (.getBoundingClientRect track))}) (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. - {:class (when lane? "lane") - :on-pointer-move slide + {: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 @@ -871,7 +824,6 @@ (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") @@ -904,13 +856,7 @@ [: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) @@ -924,39 +870,25 @@ (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 (js/Math.floor in) + {:selection select :label label :in 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 "Drag the cut · the clip before it ends here and the one after it starts here" + {:title "Left: trim left · center: roll cut · right: trim right" :on-pointer-down (fn [^js e] - (begin! e (nth select 3) :roll select (nth (:select prev) 3) nil))}] + (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))))}] [:span.tl-edge.in {:title "Drag start" :on-pointer-down #(begin! % (nth select 3) :in select nil nil)}])) (when select @@ -995,13 +927,8 @@ renaming (r/atom nil) draft (r/atom "")] (let [clip @(rf/subscribe [::render/clip]) - ;; 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]) + frames (max 1 (or @(rf/subscribe [::render/frames]) 1)) + frame @(rf/subscribe [::playback/frame]) store @(rf/subscribe [::render/store]) selection @(rf/subscribe [::sub/selection]) target @(rf/subscribe [::sub/target]) @@ -1014,26 +941,18 @@ ;; 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 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. + ;; 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. 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 drop-span :keys []}) - lane-ghost (when (and drop drop-lane) + :span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))] + :keys []}) + lane-ghost (when (and drop drop-lane (not (:sound? drop))) {:id ::drop :label (str "+ " (:label drop)) :ghost? true - :span drop-span}) + :span [(:frame drop) (+ (:frame drop) (or (:frames drop) 1))]}) chosen (when (= :node (first selection)) (nth selection 3)) picture (cond->> (cond->> (rows clip open expanded chosen) lane-ghost @@ -1052,14 +971,7 @@ (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))) - ;; 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)))] + step (* 10 (js/Math.ceil (/ frames 100)))] [:section.pane.time [transport] [:div.tl-body @@ -1111,7 +1023,7 @@ :style {"--tick" (str (* 100 (/ 5 frames)) "%")}} [:div.tl-ruler {:on-pointer-down (fn [^js e] - (rf/dispatch [::pb/seek (seek-to e frames)]) + (rf/dispatch [::pb/seek (frame-at e frames)]) (reset! scrubbing true) ;; Capture is what keeps a drag scrubbing once it ;; leaves the ruler, and it is an ENHANCEMENT: it @@ -1127,7 +1039,7 @@ (catch :default _ nil))) :on-pointer-move (fn [^js e] (when @scrubbing - (rf/dispatch [::pb/seek (seek-to e frames)]))) + (rf/dispatch [::pb/seek (frame-at e frames)]))) :on-pointer-up (fn [_] (reset! scrubbing false)) :on-pointer-cancel (fn [_] (reset! scrubbing false))} (doall @@ -1139,8 +1051,7 @@ (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 - :selection selection}]) + {:clip clip :store store :open open :frame frame}]) {: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 80be494..1da58ad 100644 --- a/frontend/test/arthur/domain/cadence_test.cljs +++ b/frontend/test/arthur/domain/cadence_test.cljs @@ -36,28 +36,8 @@ (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)))) - ;; `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)))))))) + (is (= 7 (:frame (nest/inside doc store :main [:mark] 3)))) + (is (= [0 24] (:span (first (timeline/rows doc :main #{}))))))) (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) @@ -89,27 +69,6 @@ [track] (nest/audio-tracks slow :main)] (is (= 0.5 (* (get-in track [:time :rate]) (/ (:fps slow) (:fps track))))))))) -(deftest generated-faces-own-their-footage-sound - (let [face (fn [id] {:id id :fps 30 :frames 20 :nodes {}}) - frozen {:fps 30 :subjects {:face-1 {} :face-2 {}} - :symbols {:main {:id :main :fps 30 :frames 20 - :nodes {:face-1 {:id :face-1 :kind :instance :z "a1" - :source {:symbol :face-1}} - :face-2 {:id :face-2 :kind :instance :z "a2" - :source {:symbol :face-2}}}} - :face-1 (face :face-1) - :face-2 (face :face-2)}} - {doc :clip take :sid} (bring/take (clip/blank) frozen "take" "video" [3 13]) - alone (clip/place-symbol doc nil :main :face-1 4 :placed-face nil)] - (is (nil? (get-in doc [:symbols take :nodes :sound])) - "the wrapper does not own a sound the face would lose") - (is (= {:footage "video"} - (get-in doc [:symbols :face-1 :nodes :sound :source]))) - (is (= 1 (count (nest/audio-tracks doc take))) - "the same take recording is not mixed once per detected face") - (is (= 1 (count (nest/audio-tracks alone :main))) - "placing a generated face by itself brings its associated sound"))) - ;; --------------------------------------------------------------------------- ;; the preserve-snap ;; diff --git a/frontend/test/arthur/domain/nest_test.cljs b/frontend/test/arthur/domain/nest_test.cljs index d224a4f..4dd04d9 100644 --- a/frontend/test/arthur/domain/nest_test.cljs +++ b/frontend/test/arthur/domain/nest_test.cljs @@ -281,78 +281,3 @@ (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 8aade43..9d5601a 100644 --- a/frontend/test/arthur/events/lane_test.cljs +++ b/frontend/test/arthur/events/lane_test.cljs @@ -115,73 +115,3 @@ 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 80a3afa..c6a66ca 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -56,18 +56,6 @@ --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; @@ -987,14 +975,6 @@ 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, @@ -1059,38 +1039,11 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } overflow: hidden; } -/* 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.on { background: var(--sel-bg); } .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); } @@ -1185,22 +1138,6 @@ 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); } @@ -1218,10 +1155,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .tl-junction { position: absolute; top: -1px; - /* 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; + left: 0; bottom: -1px; width: 12px; transform: translateX(-50%); @@ -1229,18 +1163,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } touch-action: none; z-index: 3; } -/* `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-cel-label { display: block; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } .tl-span.ghost { background: transparent; border: 1px dashed var(--sel);