feat: lane editing, drag-select, frame snapping, drawing autocommit

Chunk 5 (draft/edit lane) + follow-up polish on the marks-first flow.

- Lane bars are [lo hi mark-id]; draft/edit bars are interactive via mark-drag!:
  click = draw on that mark, body-drag = move, edge handles = resize. All route
  through ::reroll-proxy -> roll-proxy (in-place within a clip, add/drop across a
  boundary). Active mark shows a ring in the lane and an accent row in the pane.
- Timeline drag-select: drag across clips/empty space to select a region (proxy +
  auto-draw); a plain click moves the playhead (scrubbing works in marking mode).
- Frame accuracy: snap OTIO's fractional RationalTime to whole frames at the seed
  boundary (from-otio) so nothing sub-frame enters the model; round display too.
- Drawing autocommits (no save/cancel buttons — the form's Save/Cancel is the
  rollback net); toolbar moved to a bottom strip off the video; draw mode tears
  down on form finish.

Tests: from-otio snapping, per-mark lane bars carry mark-id. Suite green
(pre-existing json-roundtrip failure only); app compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-05 03:29:43 -04:00
parent b66d189a31
commit 213602c4cb
6 changed files with 245 additions and 75 deletions

View file

@ -68,12 +68,18 @@ So "the title is the autocomplete": one control forks create-vs-associate.
## Chunk 5 — Lane draft/edit interactions
- [x] **(done early — correctness)** Lane bars computed PER MARK (`scene/lane-bars`): two abutting-but-distinct marks stay separate bars instead of fusing; a cross-clip proxy still coalesces to one bar. Was a latent bug (global `merge-bars` over all marks' source ranges), now unambiguous with per-mark proxies. Tested.
- [ ] Tag each lane bar with its mark-id so hover/drag/highlight matches exactly one mark (`:bars` is still flat `[lo hi]`; `partition-by :mark` in `lane-bars` makes this a small enrichment).
- [ ] Click a draft/edit mark → drawing mode for that mark (sets active).
- [ ] Drag whole mark left/right.
- [ ] Edge handles resize: within-clip → edit end mark in place; across boundary → call `roll-start`/`roll-end`.
- [ ] Active-mark highlight in the lane.
- [ ] Shared mark-range look for draft and edit; only chrome above differs.
- [x] Each lane bar is now `[lo hi mark-id]` (`lane-bars`); consumers that want only the range still destructure `[lo hi]`. Test updated.
- [x] Click a draft/edit mark's bar → drawing mode for that mark (via `mark-drag!` — a no-move gesture is a click → `::start-drawing`, which also sets active).
- [x] Drag whole mark left/right → `::reroll-proxy` with both endpoints shifted (`mark-drag!` `:move`).
- [x] Edge handles resize (`.bar-handle` at each edge → `mark-drag!` `:start`/`:end`) → `::reroll-proxy` → `roll-proxy`: within-clip edits the boundary mark in place (id stable), across-boundary adds/drops a clip. Endpoints clamped to `[0,len]`, min width 1.
- [x] Active-mark highlight in the lane (box-shadow ring + z-index on the bar whose mark = `::active-mark`).
- [x] Shared look: draft & saved bars use the same `.ann-bar` (draft dashed/translucent, saved solid); pane proxy rows carry the same `.active-mark` highlight as the lane.
- [x] **Drag-to-select on the timeline** (`region-select!`): while authoring, drag across clips or empty track space → a live preview band → one selection (proxy + auto-draw). Click a clip still does the two-click flow (no-move → `on-click` fallback). This is the "drag a range" input deferred from Chunk 2.
- [x] **Visible edge handles**: each draft bar edge shows a small paper/ink pill grip (was invisible ew-resize zone).
- [x] **Draw mode tears down on form save/cancel** (`::finish-edit` clears `:draw`/`:active-mark`/`:pt`/`:draft-stage`) — no more stuck overlay.
**Rough edges (polish later):** very narrow bars (<~18px) are mostly handle. Numeric endpoint editing in the pane rows is still read-only (the lane is the editing surface).
## Chunk 6 — Pane collapsed display + active indicator

View file

@ -88,9 +88,10 @@ body { overflow: hidden; background: var(--desktop); background-size: 4px 4px;
.draw-overlay { position: absolute; inset: 0; pointer-events: none; }
.draw-overlay.editing { pointer-events: auto; cursor: crosshair; }
.draw-overlay svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.draw-tools { position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 4px; padding: 3px 5px; line-height: 1;
background: var(--paper); border: 1px solid var(--ink); box-shadow: 2px 2px 0 rgba(0,0,0,.4); }
/* a control strip across the BOTTOM of the video, not a floating overlay over it */
.draw-tools { position: absolute; left: 0; right: 0; bottom: 0; top: auto;
display: flex; align-items: center; justify-content: center; gap: 4px; padding: 4px 6px; line-height: 1;
background: var(--paper); border-top: 2px solid var(--ink); box-shadow: none; }
.draw-tools button { font-size: 13px; background: var(--paper); color: var(--ink);
border: 1px solid var(--ink); border-radius: 0; padding: 1px 6px; cursor: pointer; }
.draw-tools button.active { background: var(--ink); }
@ -695,6 +696,10 @@ html.dark .timeline-head {
/* binding notes to annotations/marks (annotation form + cards) */
.mark-block { margin-bottom: 4px; }
/* the currently-selected/last-touched mark while authoring — so you know which
one a drawing binds to, and which you're about to delete */
.mark-block.active-mark { box-shadow: inset 3px 0 0 var(--ink); background: var(--shade, rgba(128,128,128,.14));
border-radius: 2px; padding: 2px 0 2px 3px; margin-left: -3px; }
.note-drop { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-height: 20px;
margin: 2px 0 2px 14px; padding: 2px 4px; border: 1px dashed var(--mute); border-radius: 0; }
.note-drop-label { font-size: 10px; color: var(--mute); }

View file

@ -453,23 +453,23 @@
:gid (or existing (keyword (str "draw-" (random-uuid))))
:new? (nil? existing)})))))
;; leave draw mode. Strokes autocommit as you draw, so this is just "done" —
;; nothing to save or discard here (the form's Save/Cancel is the rollback net).
(rf/reg-event-db ::cancel-drawing (fn [db _] (assoc-in db [:view :draw] nil)))
;; commit strokes: upsert the drawing entity (persist now, it's standalone) and
;; bind it onto the mark (rides the annotation's Save, like note bindings). Empty
;; strokes ⇒ discard (and drop a now-empty existing binding).
(rf/reg-event-fx ::save-drawing
;; autocommit strokes (fired on every change, not a button): upsert the drawing
;; entity + bind it onto the mark, and KEEP draw mode open. The binding rides the
;; annotation, so cancelling create/edit reverts it. Empty ⇒ drop the binding.
(rf/reg-event-fx ::commit-drawing
(fn [{:keys [db]} [_ strokes]]
(let [{:keys [ann mark-id gid]} (get-in db [:view :draw])]
(if (empty? strokes)
{:db (-> db (update-mark ann mark-id #(update % :drawings rm-in gid))
(assoc-in [:view :draw] nil))}
{:db (update-mark db ann mark-id #(update % :drawings rm-in gid))}
(let [drawing (-> (get-in db [:scene :groups gid] {:seed (rand-int 1000000)})
(assoc :type :drawing :strokes (vec strokes)))
db (-> db (assoc-in [:scene :groups gid] drawing)
(update-mark ann mark-id #(update % :drawings add-in gid))
(assoc-in [:view :draw] nil)
(assoc-in [:view :active-mark] mark-id) ; last drawn stays active
(assoc-in [:view :active-mark] mark-id)
(assoc :save-error nil))]
(merge {:db db} (persist-note-fx db gid)))))))
@ -599,9 +599,15 @@
(assoc-in [:view :edit-return] (when-not root? gid)))))))
(rf/reg-event-fx ::finish-edit
(fn [{:keys [db]} _]
;; leaving the form (save OR cancel): tear down all authoring
;; transients so draw mode / pending points don't linger.
(let [db (-> db (assoc-in [:view :draw] nil)
(assoc-in [:view :active-mark] nil)
(assoc-in [:view :pt] nil)
(assoc-in [:view :draft-stage] nil))]
(if-let [g (get-in db [:view :edit-return])]
(enter-ctx (assoc-in db [:view :edit-return] nil) #(conj % g))
{:db db})))
{:db db}))))
(rf/reg-event-db ::draft-focus (fn [db [_ pt]] (assoc-in db [:view :pt] pt)))
;; cancelling a draft is local only; saving a real annotation / deleting one
;; pushes a delta to the backend (which merges + attributes it).
@ -698,6 +704,32 @@
(into (subvec marks 0 i) (subvec marks (inc i))))
(assoc-in [:view :pt] {:seg (:ref keep) :f (:at keep) :mark mark :i i})))))
;; complete a selection on the active draft: wrap the run [lo hi) in a proxy (a
;; synthetic clip), give the annotation one mark referencing it, make it active,
;; seek to its start, and drop into drawing mode ("select a range → you're drawing").
(defn- select-range-fx [db gid g lo hi]
(let [scene (:scene db)
p (scene/make-proxy scene (:parent g) lo hi)
pgid (keyword (str "prox-" (random-uuid)))
mid (str (random-uuid))
sf (scene/local->source (scene/content-segments scene (:parent g)) lo)]
{:db (-> db (assoc-in [:scene :groups pgid] p)
(update-in [:scene :groups gid :marks] conj (scene/proxy-ref mid pgid))
(assoc-in [:view :active-mark] mid)
(assoc-in [:view :playheads (:parent g)] lo)
(assoc-in [:view :pt] :new))
:player/seek (when sf (/ sf (:fps db)))
:fx [[:dispatch [::start-drawing gid mid]]]}))
;; drag a region directly on the timeline (context-local frames) → one selection.
(rf/reg-event-fx
::draft-select-range
(fn [{:keys [db]} [_ lo hi]]
(let [[gid g] (some (fn [[gid g]] (when (:draft g) [gid g])) (get-in db [:scene :groups]))]
(if (and gid (< lo hi))
(select-range-fx db gid g lo hi)
{:db db}))))
(rf/reg-event-fx
::draft-click-seg
(fn [{:keys [db]} [_ seg-id frame]]
@ -724,21 +756,8 @@
{:db (-> db (update-in [:scene :groups gid :marks]
#(into (into (subvec % 0 i) run) (subvec % i)))
(assoc-in [:view :pt] :new))})
;; new selection: wrap the run in a proxy (a synthetic clip in the
;; pool), give the annotation one mark referencing the whole proxy,
;; make it active, seek to its start, and drop straight into drawing
;; mode on it — "after you select a range you're automatically drawing".
(let [p (scene/make-proxy scene (:parent g) lo hi)
pgid (keyword (str "prox-" (random-uuid)))
mid (str (random-uuid))
sf (scene/local->source segs lo)]
{:db (-> db (assoc-in [:scene :groups pgid] p)
(update-in [:scene :groups gid :marks] conj (scene/proxy-ref mid pgid))
(assoc-in [:view :active-mark] mid)
(assoc-in [:view :playheads (:parent g)] lo)
(assoc-in [:view :pt] :new))
:player/seek (when sf (/ sf (:fps db)))
:fx [[:dispatch [::start-drawing gid mid]]]})))
;; new selection (two-click): same completion as a timeline drag.
(select-range-fx db gid g lo hi)))
{:db (assoc-in db [:view :pt] {:seg seg-id :f (or frame 0)})}))))
;; remove mark `i` from `gid`; if it referenced a proxy, drop the now-orphaned
@ -754,6 +773,26 @@
#(into (subvec % 0 i) (subvec % (inc i))))
prox? (update-in [:scene :groups] dissoc pid)))))
;; live lane edit of a proxy mark: re-derive its run for a new context-local
;; range [la lb) (whole-mark move, or one edge dragged). roll-proxy reuses the
;; ids of interior pieces (stable), adds/drops boundary marks as edges cross clip
;; boundaries. The annotation's {:ref proxy} mark and its drawing bindings never
;; move; only the proxy's internals change. Persists on the annotation's Save.
(rf/reg-event-db
::reroll-proxy
(fn [db [_ ann mark-id la lb]]
(let [scene (:scene db)
pid (->> (get-in scene [:groups ann :marks])
(some #(when (= mark-id (:id %)) (get-in % [:start :ref]))))
proxy (get-in scene [:groups pid])
ctx (:parent (get-in scene [:groups ann]))
len (scene/length (scene/content-segments scene ctx))
la* (max 0 (min la (dec len)))
lb* (max (inc la*) (min lb len))]
(if (= :proxy (:type proxy))
(assoc-in db [:scene :groups pid] (scene/roll-proxy scene ctx proxy la* lb*))
db))))
;; transclusion: instead of creating a new annotation, append the draft's marks
;; to an EXISTING one and open it in edit mode ("the new marks suddenly added").
;; We persist the attachment now (the marks + their proxies) and discard the draft

View file

@ -275,11 +275,16 @@
"Context-local display bars for annotation `gid`, grouped PER MARK: contiguous
pieces coalesce WITHIN a mark but never across marks, so two abutting-but-
distinct marks stay separate bars — the lane bar matches each mark's highlight
1:1 instead of fusing neighbours. `ctx-segs` = content-segments of ctx."
1:1 instead of fusing neighbours. Each bar is `[lo hi mark-id]` (the mark-id
lets the lane hit-test / highlight / edit one mark; consumers that only want
the range destructure `[lo hi]` and ignore it). `ctx-segs` = content-segments."
[scene ctx gid ctx-segs]
(->> (nested-src-marks scene ctx gid)
(partition-by :mark)
(mapcat (fn [ss] (merge-bars (mapcat (fn [{[a b] :src}] (pieces ctx-segs a b)) ss))))
(mapcat (fn [ss]
(let [mid (:mark (first ss))]
(map (fn [[lo hi]] [lo hi mid])
(merge-bars (mapcat (fn [{[a b] :src}] (pieces ctx-segs a b)) ss))))))
vec))
(defn clip-loss?
@ -433,10 +438,12 @@
anns))
(defn- clip-row
"Editor row {:s … :e …} for a single-clip/subclip ref mark, in mark time."
"Editor row {:s … :e …} for a single-clip/subclip ref mark, in mark time.
Frames are rounded for display — OTIO's fractional media offsets can leave a
ref's :at sub-frame, and the editor/labels want whole frames."
[scene {:keys [start end]}]
{:s {:seg (:ref start) :f (at->frame scene (:ref start) (:at start))}
:e {:seg (:ref end) :f (at->frame scene (:ref end) (:at end))}})
{:s {:seg (:ref start) :f (js/Math.round (at->frame scene (:ref start) (:at start)))}
:e {:seg (:ref end) :f (js/Math.round (at->frame scene (:ref end) (:at end)))}})
(defn mark-row
"One editor row for a mark. A plain clip/subclip ref collapses to {:s :e}
@ -467,21 +474,27 @@
(defn from-otio
"Seed a scene from tl.otio/parse output: a video :track per source track, one
clip mark-group per clip (source range + track), and the root timeline. The
otio is only a seed — nothing here reads it again."
otio is only a seed — nothing here reads it again.
Frames are SNAPPED to whole integers here, at the one boundary where OTIO's
fractional RationalTime enters: a frame-based tool has no meaning below a whole
frame, so we round once, at the source, and everything downstream (source
ranges, mark :at offsets, seeks, labels) stays frame-accurate by construction."
[{:keys [duration tracks]}]
(let [vtracks (filter #(= :video (:kind %)) tracks)
(let [r (fn [x] (js/Math.round x))
vtracks (filter #(= :video (:kind %)) tracks)
track-map (into {} (map (fn [t] [(keyword (str "t" (:index t))) {:name (:name t)}])) vtracks)
clips (into {} (for [t vtracks c (:clips t)]
[(keyword (:id c))
{:type :clip :parent nil :name (:name c)
:start (:start c) ; timeline position (frames)
:start (r (:start c)) ; timeline position (frames)
:marks [{:id (keyword (str (:id c) "-m"))
:start (:media-in c)
:end (+ (:media-in c) (:duration c))
:start (r (:media-in c))
:end (r (+ (:media-in c) (:duration c)))
:track (keyword (str "t" (:index t)))}]}]))]
{:tracks track-map
:groups (assoc clips :root {:type :timeline :parent nil
:marks [{:id :root-m :start 0 :end duration}]})}))
:marks [{:id :root-m :start 0 :end (r duration)}]})}))
(defn clip-name [scene gid] (:name (grp scene gid)))
@ -538,7 +551,7 @@
[scene ctx gid]
(mapv (fn [{:keys [id lo]}]
(let [st (:start (some #(when (= id (:id %)) %) (:marks (grp scene gid))))]
{:local lo :seg (:ref st) :f (at->frame scene (:ref st) (:at st))}))
{:local lo :seg (:ref st) :f (js/Math.round (at->frame scene (:ref st) (:at st)))}))
(runs scene ctx gid)))
(defn linkables

View file

@ -278,9 +278,11 @@
(reset! undo []) (reset! cur nil) (reset! tool :pencil)
(reset! pen-color color) (reset! pen-w draw-width))
(when (and (nil? draw) @sess) (reset! sess nil))
(let [push! #(swap! undo conj @strokes)
(let [commit! #(rf/dispatch [::events/commit-drawing @strokes]) ; autocommit on every change
push! #(swap! undo conj @strokes)
erase-at (fn [xy] (when-let [i (first (keep-indexed (fn [i s] (when (near-stroke? xy s) i)) @strokes))]
(push!) (swap! strokes #(vec (concat (subvec % 0 i) (subvec % (inc i)))))))]
(push!) (swap! strokes #(vec (concat (subvec % 0 i) (subvec % (inc i)))))
(commit!)))]
[:div.draw-overlay {:class (when draw "editing")}
[:svg {:viewBox "0 0 1 1" :preserveAspectRatio "none" :ref #(reset! svg %)
:on-pointer-down
@ -296,7 +298,7 @@
@cur (swap! cur update :pts conj xy)))))
:on-pointer-up
(when draw (fn [_] (when-let [c @cur]
(when (> (count (:pts c)) 1) (push!) (swap! strokes conj c))
(when (> (count (:pts c)) 1) (push!) (swap! strokes conj c) (commit!))
(reset! cur nil))))}
(if draw
[:g [:g (drawing-strokes @strokes (or (:seed drawing) 0) variant)]
@ -313,10 +315,11 @@
[:input.draw-wid {:type "range" :min 1 :max 8 :step 0.5 :title "Stroke size"
:value @pen-w :on-change #(reset! pen-w (js/parseFloat (.. % -target -value)))}]
[:button {:title "Undo" :disabled (empty? @undo)
:on-click #(when (seq @undo) (reset! strokes (peek @undo)) (swap! undo pop))} "↶"]
:on-click #(when (seq @undo) (reset! strokes (peek @undo)) (swap! undo pop) (commit!))} "↶"]
[:span.draw-spacer]
[:button.draw-done {:title "Done" :on-click #(rf/dispatch [::events/save-drawing @strokes])} "✓"]
[:button {:title "Cancel" :on-click #(rf/dispatch [::events/cancel-drawing])} "✕"]])]))))
;; strokes autocommit as you draw; this just leaves draw mode (the
;; form's Save/Cancel is the rollback net — no separate save/discard).
[:button.draw-done {:title "Done drawing" :on-click #(rf/dispatch [::events/cancel-drawing])} "Done"]])]))))
(defn video-monitor []
;; The src carries a #t= media fragment for the deep-linked playhead. That is
@ -454,6 +457,65 @@
(.addEventListener js/document "mousemove" move)
(.addEventListener js/document "mouseup" up)))
(defn- mark-drag!
"Pointer drag for a draft/edit mark's lane bar. `mode` = :move (whole mark) |
:start | :end (one edge). Converts the clientX delta to a frame delta and
rerolls the proxy live; a gesture that never moves past threshold is treated
as a plain click → draw on the mark. `lo`/`hi` = the mark's current extent."
[ann mark-id mode lo hi fps zoom ev]
(.stopPropagation ev) (.preventDefault ev)
(let [start-x (.-clientX ev)
moved? (atom false)
d-of (fn [e] (* (/ (- (.-clientX e) start-x) zoom) fps))
move (fn [e]
(let [d (d-of e)]
(when (or @moved? (> (js/Math.abs d) 3))
(reset! moved? true)
(let [[la lb] (case mode
:move [(+ lo d) (+ hi d)]
:start [(+ lo d) hi]
:end [lo (+ hi d)])]
(rf/dispatch [::events/reroll-proxy ann mark-id
(js/Math.round la) (js/Math.round lb)])))))
up (fn up [_]
(.removeEventListener js/document "mousemove" move)
(.removeEventListener js/document "mouseup" up)
(when-not @moved? ; a click, not a drag → draw
(goto! lo true)
(rf/dispatch [::events/start-drawing ann mark-id])))]
(.addEventListener js/document "mousemove" move)
(.addEventListener js/document "mouseup" up)))
;; live drag-to-select region on the timeline (context-local [lo hi]) while
;; authoring, or nil. Deref'd in the timeline render to draw the preview band.
(defonce ^:private region-sel (r/atom nil))
(defn- region-select!
"On the timeline while authoring: DRAG to select a region [lo hi) → one
selection (a proxy); a plain CLICK (no drag) just moves the playhead, so you
can still scrub in marking mode. `content` is the coord ref."
[content fps zoom ev]
(.preventDefault ev)
(let [rect (.getBoundingClientRect content)
sx (.-clientX ev)
to (fn [cx] (max 0 (* (/ (- cx (.-left rect)) zoom) fps)))
a (to sx)
moved? (atom false)
mv (fn [e]
(when (or @moved? (> (js/Math.abs (- (.-clientX e) sx)) 4))
(reset! moved? true)
(let [b (to (.-clientX e))] (reset! region-sel [(min a b) (max a b)]))))
up (fn up [e]
(.removeEventListener js/document "mousemove" mv)
(.removeEventListener js/document "mouseup" up)
(let [was @moved? b (to (.-clientX e)) lo (min a b) hi (max a b)]
(reset! region-sel nil)
(if (and was (> (- hi lo) 0.5))
(rf/dispatch [::events/draft-select-range (js/Math.round lo) (js/Math.round hi)])
(goto! a))))] ; click → move the playhead
(.addEventListener js/document "mousemove" mv)
(.addEventListener js/document "mouseup" up)))
(defn- playhead-fx
"Renders nothing. Isolates the playhead-driven side effects — positioning the
(imperative) playhead line and the follow-scroll during playback — so a moving
@ -561,6 +623,7 @@
anns @(rf/subscribe [::subs/annotations])
thumbs @(rf/subscribe [::subs/thumbnails])
authoring? (some? @(rf/subscribe [::subs/draft-group]))
active-mark @(rf/subscribe [::subs/active-mark])
linking @(rf/subscribe [::subs/linking])
len @(rf/subscribe [::subs/length])
width (px len fps zoom)
@ -596,10 +659,14 @@
;; annotation bars — solid for saved, dashed for the in-progress draft
;; visible annotations: full bars with labels, greedily packed into lanes
(for [a visible
[j [lo hi]] (map-indexed vector (:bars a))]
[j [lo hi mid]] (map-indexed vector (:bars a))]
^{:key (str (:id a) "-" j)}
[:div.ann-bar {:title (:name a)
:on-mouse-down (when-not (:draft a)
:on-mouse-down
(if (:draft a)
;; draft/edit: drag the whole mark, or click (no drag)
;; to draw on it. Both make it the active mark.
(fn [e] (mark-drag! (:id a) mid :move lo hi fps zoom e))
(fn [e] (.stopPropagation e)
(if linking
(do (.preventDefault e) ; pick: link to this timeline
@ -613,9 +680,25 @@
:style {:top (+ 2 (* (lane-of (:id a)) 18)) :height 14
:left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom))
:background (str (:color a) (if (:draft a) "44" "cc"))
:cursor (if (:draft a) "default" "pointer")
:cursor (if (:draft a) "grab" "pointer")
:border-radius 2
:border (str (if (:draft a) "1px dashed " "1px solid ") (:color a))}}])
:border (str (if (:draft a) "1px dashed " "1px solid ") (:color a))
:box-shadow (when (and mid (= mid active-mark)) "0 0 0 2px var(--ink)")
:z-index (when (and mid (= mid active-mark)) 3)}}
;; edge handles: drag to roll one endpoint (adds/drops clips as it
;; crosses a boundary via roll-proxy). Only on the draft/edit mark.
(when (:draft a)
(let [grip {:width 3 :height 9 :border-radius 2
:background "var(--paper)" :border "1px solid var(--ink)"}
zone {:position "absolute" :top 0 :width 9 :height "100%" :cursor "ew-resize"
:display "flex" :align-items "center" :justify-content "center"}]
[:<>
[:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :start lo hi fps zoom e))
:style (assoc zone :left -3)}
[:div {:style grip}]]
[:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :end lo hi fps zoom e))
:style (assoc zone :right -3)}
[:div {:style grip}]]]))])
;; visible annotation labels
(for [a visible :let [[lo _] (first (:bars a))] :when lo]
^{:key (str "lbl-" (:id a))}
@ -652,12 +735,16 @@
;; handler needs no render-time playhead dep
(position-playhead! fps zoom @(rf/subscribe [::subs/playhead])))}
[:div.content.track-content {:ref (fn [n] (reset! content n))
:on-mouse-down #(scrub! @content fps zoom %)
;; while authoring, drag empty space to select a region
:on-mouse-down (if authoring?
#(region-select! @content fps zoom %)
#(scrub! @content fps zoom %))
:style {:width width :height tracks-h}}
(for [[i t] (map-indexed vector tracks)]
^{:key (str "lane-" (:id t))}
[:div.track-lane {:style {:top (* i row-h) :width width :height row-h}}])
;; clips (while authoring, click to fill the focused endpoint)
;; clips: while authoring, DRAG across them to select a region, or
;; CLICK a clip to fill the focused endpoint (two-click flow).
(for [{:keys [mark track src local] :as seg} segs]
(let [[c d] local]
^{:key (str mark "-" (first src))}
@ -667,12 +754,18 @@
(if linking
(commit-link! (scene/seg-point scene seg 0)
(clip-label scene segs (:mark seg)))
(rf/dispatch [::events/draft-click-seg (:mark seg)]))))
(region-select! @content fps zoom e))))
:style {:left (px c fps zoom) :width (max 1 (px (- d c) fps zoom))
:top (* (get track-y track 0) row-h) :height (- row-h 2)
:line-height (str (- row-h 2) "px") :position "absolute"}}
[clip-thumbnail-layer thumbs fps zoom row-h seg]
[:div.clip-name (clip-label scene segs mark)]]))]]]]))))
[:div.clip-name (clip-label scene segs mark)]]))
(when-let [[lo hi] @region-sel]
[:div.region-preview {:style {:position "absolute" :top 0 :height tracks-h
:left (px lo fps zoom) :width (max 2 (px (- hi lo) fps zoom))
:background "rgba(78,143,194,0.22)"
:border "1px solid rgba(78,143,194,0.85)"
:pointer-events "none" :z-index 5}}])]]]]))))
;; --- links: inline markdown chips that seek the timeline ------------------
;; A link is a ref-point named in the content as `[label](mark:ref@at)` (see
@ -1337,7 +1430,7 @@
(when (map? pt)
[:div.mark-row
[:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt))
" @" (:f pt) "f")]]
" @" (js/Math.round (:f pt)) "f")]]
[:span.mark-arrow "→"]
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
:placeholder "click end clip..."
@ -1353,7 +1446,7 @@
:placeholder "click a clip..."
:on-pick #(when-let [p (local->draft-point segs (:local %))]
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]])
[:div.form-hint "Click a clip in the timeline to set a start, then a clip for the end."]
[:div.form-hint "Drag across the timeline to select a range (click to move the playhead)."]
;; the title IS the create/associate control: type a new title to make a
;; fresh annotation with these marks, or pick an existing annotation to
;; add them to it (dropping straight into its edit form). Transclusion.

View file

@ -222,12 +222,13 @@
(s/proxy-ref :m/2 :p2)]}))
segs (s/content-segments scene :root)
bars (s/lane-bars scene :root :ann segs)]
(is (= [[0 200] [200 300]] bars)) ; two marks → two bars, kept apart
(is (= [[0 200 :m/1] [200 300 :m/2]] bars)) ; two marks → two bars, tagged + apart
(is (= [[0 200] [200 300]] (mapv #(subvec % 0 2) bars))) ; ranges still destructure as [lo hi]
;; and a single cross-clip proxy on its own is one contiguous bar
(let [one (-> base (with-group :p1 p1)
(with-group :ann {:type :annotation :parent :root
:marks [(s/proxy-ref :m/1 :p1)]}))]
(is (= [[0 200]] (s/lane-bars one :root :ann (s/content-segments one :root))))))))
(is (= [[0 200 :m/1]] (s/lane-bars one :root :ann (s/content-segments one :root))))))))
(deftest proxy-survives-restore-roundtrip
(testing "a proxy group (string :type/:parent, string mark ids/refs from JSON)
@ -293,6 +294,19 @@
(is (= #{:t0 :t1} (s/tracks segs)))
(is (= 200 (s/length segs)))))))
(deftest from-otio-snaps-fractional-frames
(testing "OTIO's fractional RationalTime is rounded to whole frames at seed"
(let [parsed {:fps 24 :duration 199.6
:tracks [{:index 0 :kind :video :name "W"
:clips [{:id "t0-c0" :name "a" :start 0.2 :media-in 188.87 :duration 100.4}]}]}
scene (s/from-otio parsed)
mark (first (get-in scene [:groups :t0-c0 :marks]))]
(is (= 0 (get-in scene [:groups :t0-c0 :start]))) ; 0.2 -> 0
(is (= 189 (:start mark))) ; media-in 188.87 -> 189
(is (= 289 (:end mark))) ; 188.87+100.4=289.27 -> 289
(is (= 200 (get-in scene [:groups :root :marks 0 :end]))) ; 199.6 -> 200
(is (every? integer? [(:start mark) (:end mark)])))))
;; =========================================================================
;; Suite 4 — draft rows <-> marks (the two-input editor)
;; =========================================================================