diff --git a/tl/annotation_flow_plan.md b/tl/annotation_flow_plan.md index 556830c..4d088c6 100644 --- a/tl/annotation_flow_plan.md +++ b/tl/annotation_flow_plan.md @@ -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 diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index 93cedcd..eac3ea9 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -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); } diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index d4c73c1..619033c 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -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 diff --git a/tl/src/tl/scene.cljs b/tl/src/tl/scene.cljs index 4321970..c4d96a7 100644 --- a/tl/src/tl/scene.cljs +++ b/tl/src/tl/scene.cljs @@ -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 diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 26c7f7b..8a159c3 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -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. diff --git a/tl/test/tl/scene_test.cljs b/tl/test/tl/scene_test.cljs index 79dcd2e..44b62e8 100644 --- a/tl/test/tl/scene_test.cljs +++ b/tl/test/tl/scene_test.cljs @@ -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) ;; =========================================================================