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:
parent
b66d189a31
commit
213602c4cb
6 changed files with 245 additions and 75 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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]} _]
|
||||
(if-let [g (get-in db [:view :edit-return])]
|
||||
(enter-ctx (assoc-in db [:view :edit-return] nil) #(conj % g))
|
||||
{:db 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}))))
|
||||
(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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,26 +659,46 @@
|
|||
;; 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)
|
||||
(fn [e] (.stopPropagation e)
|
||||
(if linking
|
||||
(do (.preventDefault e) ; pick: link to this timeline
|
||||
(commit-link! {:kind :timeline :ref (:id a)} (:name a)))
|
||||
;; jump the playhead to this mark and scroll its
|
||||
;; card into view (don't drill into the timeline)
|
||||
(do (goto! lo true)
|
||||
(when-let [node (js/document.getElementById
|
||||
(str "ann-" (name (:id a))))]
|
||||
(.scrollIntoView node #js {:block "center" :behavior "smooth"}))))))
|
||||
: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
|
||||
(commit-link! {:kind :timeline :ref (:id a)} (:name a)))
|
||||
;; jump the playhead to this mark and scroll its
|
||||
;; card into view (don't drill into the timeline)
|
||||
(do (goto! lo true)
|
||||
(when-let [node (js/document.getElementById
|
||||
(str "ann-" (name (:id a))))]
|
||||
(.scrollIntoView node #js {:block "center" :behavior "smooth"}))))))
|
||||
: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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
;; =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue