Compare commits

..

2 commits

Author SHA1 Message Date
Your Name
f5a39aee39 Keep footage audio with generated face symbols 2026-10-01 23:38:16 -04:00
Your Name
0cb9d0ebf6 Unify timeline placement and gesture geometry 2026-10-01 23:35:22 -04:00
17 changed files with 767 additions and 136 deletions

66
docs/one-grid-plan.md Normal file
View file

@ -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.

View file

@ -64,9 +64,16 @@
`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 — 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.
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.
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
@ -76,20 +83,27 @@
[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-> (-> 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}}))
:clip (cond-> (-> (reduce (fn [document face]
(assoc-in document [:symbols face :nodes :sound] sound))
c sound-hosts)
(assoc-in [:symbols sid :name] (str label)))
tracked? (-> (assoc :analysis (:analysis frozen))
(update :subjects merge (:subjects frozen))
(update :features merge (:features frozen))

View file

@ -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)))

View file

@ -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]

View file

@ -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
@ -127,7 +134,12 @@
(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."
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."
[clip sid]
(letfn [(to-local [m f] (* (:rate m) (- f (:at m))))
(to-outer [m f] (+ (:at m) (/ f (:rate m))))
@ -193,7 +205,17 @@
periods))))
nil))))
(sort-by (comp str key) nodes))))]
(vec (walk sid (clip/grid-time clip sid) [0 (clip/output-frames clip sid)] [] #{}))))
(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)))))
(defn- retime
"Node `n` with its own time map replaced by `m`, and nothing else touched: its
@ -353,7 +375,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 +413,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 +441,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 +472,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 +487,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 +503,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

View file

@ -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

View file

@ -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)]

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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])]

View file

@ -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

View file

@ -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)}}]]]

View file

@ -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)
@ -69,6 +89,27 @@
[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
;;

View file

@ -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")))

View file

@ -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")))

View file

@ -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);