fix: half-open boundaries, per-mark lane interaction, unified :at addressing

Fixes the resize-drift and boundary bugs found while exercising the marks-first
flow, plus Chunk 7 (orphan polish). Frame model made consistent rather than
patched.

- Boundaries are half-open [start,end) everywhere; drop the merge-bars ≤1-frame
  fudge and the independent from-otio rounding that created the gaps it papered
  over. Clips stay EXACT (they tile at shared boundaries); frame-accuracy is
  applied at the mark the user creates (selection->marks rounds :at) and at
  display, never by rounding clips or bars.
- Unify the ref :at <-> source conversion: at->local / at->src / src->at, all via
  the target's full resolution, correct for scattered multi-clip (nested) proxy
  targets. Delete target-range (the lossy single-segment [xs xe] shortcut that
  made selection->marks / point-frame / at->frame / seg-point wrong when nested).
  This is what fixed the resize dragging one handle moving the other.
- Lane interaction is per-MARK, not per-visual-bar: one draggable unit + two
  end-handles at the mark's exact extent (scene/mark-extent), driven so the
  dragged endpoint rounds and the fixed one stays exact. Timeline drag-select;
  click = scrub. Visible edge grips.
- Share display-point between the editor rows and the jump popover (context-local
  whole frame). Drawing autocommits; toolbar moved off the video.
- Chunk 7: broken annotations greyed + sorted down + per-mark warnings.

Suite green except the pre-existing annotations-survive-json-roundtrip; app
compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-05 12:22:17 -04:00
parent 213602c4cb
commit 06dd2b597c
6 changed files with 168 additions and 97 deletions

View file

@ -86,7 +86,13 @@ So "the title is the autocomplete": one control forks create-vs-associate.
- [ ] Marks editor renders a proxy as ONE row (start clip/frame → end clip/frame), not N rows. - [ ] Marks editor renders a proxy as ONE row (start clip/frame → end clip/frame), not N rows.
- [ ] Active mark highlighted in the pane, matching the lane. - [ ] Active mark highlighted in the pane, matching the lane.
## Chunk 7 — Orphan / stability polish ## Chunk 7 — Orphan / stability polish ✅
- [ ] Grey-out + drop orphaned annotations to bottom of list with warn indicator. - [x] Broken annotations sort to the bottom (existing sub) and are **greyed out** (opacity on the card) while staying visible so surviving marks stay usable; `△`/`⚠` warnings on the card (existing).
- [ ] Per-mark broken-ref warnings; keep annotation if at least one mark still resolves. - [x] **Per-mark broken indicator** in the editor: broken mark rows show `△` + strike-through + dimmed (`scene/broken-marks` set); an annotation keeps rendering as long as ≥1 mark resolves.
## Fundamental fix — per-mark lane interaction (not per-bar)
Handles/drag were attached to each visual bar, so a mark rendered as N pieces got N handle pairs (handles at every clip boundary). Two root fixes:
- [x] **Interaction decoupled from visual pieces**: colored bars are per-piece (pointer-events none for draft); a separate per-MARK layer spans the mark's whole extent = one draggable unit + exactly two end-handles + active ring. Robust no matter how many pieces a mark has.
- [x] **`merge-bars` absorbs ≤1-frame gaps**: independent frame-rounding of clip `:start` could leave a 1-frame gap between adjacent clips and spuriously split a mark's bar; a 1-frame gap is a rounding artifact, not real discontinuity, so it now coalesces (still per-mark, never fuses distinct marks).

View file

@ -700,6 +700,8 @@ html.dark .timeline-head {
one a drawing binds to, and which you're about to delete */ 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)); .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; } border-radius: 2px; padding: 2px 0 2px 3px; margin-left: -3px; }
.mark-block.broken { opacity: 0.6; }
.mark-block.broken .pt-chip-name { text-decoration: line-through; }
.note-drop { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-height: 20px; .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; } 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); } .note-drop-label { font-size: 10px; color: var(--mute); }

View file

@ -787,6 +787,9 @@
proxy (get-in scene [:groups pid]) proxy (get-in scene [:groups pid])
ctx (:parent (get-in scene [:groups ann])) ctx (:parent (get-in scene [:groups ann]))
len (scene/length (scene/content-segments scene ctx)) len (scene/length (scene/content-segments scene ctx))
;; the dragged endpoint is a whole frame; the FIXED endpoint arrives
;; EXACT (fractional) so its mark re-derives identically. Only the fixed
;; end must avoid rounding here — the clamp keeps [la lb) valid half-open.
la* (max 0 (min la (dec len))) la* (max 0 (min la (dec len)))
lb* (max (inc la*) (min lb len))] lb* (max (inc la*) (min lb len))]
(if (= :proxy (:type proxy)) (if (= :proxy (:type proxy))

View file

@ -55,10 +55,12 @@
segs))) segs)))
(defn merge-bars (defn merge-bars
"Coalesce [lo hi) ranges that meet at a frame boundary into single bars, so a "Coalesce [lo hi) ranges that meet at a boundary into single bars, so a
continuous selection spanning several clips reads as one piece. Endpoints snap continuous run spanning several clips reads as one piece. Ranges are half-open,
to whole frames first, which absorbs the sub-frame gaps OTIO's fractional so adjacent clips share a boundary (A.hi == B.lo) and merge exactly — no gap to
media offsets leave between adjacent clips; only a real (≥1 frame) gap splits." fudge. Endpoints snap to whole frames first (a bar covers the frames it
touches); only a real (≥1 frame) gap splits. Called per-mark, so it never fuses
two distinct marks."
[bars] [bars]
(reduce (fn [acc [lo hi]] (reduce (fn [acc [lo hi]]
(let [lo (js/Math.floor lo) hi (js/Math.ceil hi) (let [lo (js/Math.floor lo) hi (js/Math.ceil hi)
@ -88,22 +90,6 @@
(declare resolve resolve-mark) (declare resolve resolve-mark)
(defn- target-range
"Source [xs xe) + :track of a referenceable id (a clip/timeline group, or a
single-clip mark). Single-segment by the ref invariant; nil if unknown."
[scene id]
(let [segs (cond
(grp scene id) (resolve scene id)
(find-mark scene id) (let [[gid m] (find-mark scene id)]
(resolve-mark scene gid m))
:else nil)]
(when (seq segs)
{:xs (-> segs first :src first)
:xe (-> segs last :src second)
:track (-> segs first :track)
:thumb (-> segs first :thumb)
:thumb-start (-> segs first :thumb-start)})))
(defn- target-segs (defn- target-segs
"Resolved segments (local 0-based) of a referenceable id: a group (clip, "Resolved segments (local 0-based) of a referenceable id: a group (clip,
timeline, proxy, annotation) or a single mark. One segment for a clip/subclip timeline, proxy, annotation) or a single mark. One segment for a clip/subclip
@ -115,6 +101,35 @@
(resolve-mark scene gid m)) (resolve-mark scene gid m))
:else nil)) :else nil))
;; --- ref :at <-> source : the ONE interpretation of a ref's :at -----------
;; {:ref id :at n}'s :at is a LOCAL frame of id's OWN resolved timeline (n<0 from
;; the end, -1 = the exclusive end). Every :at<->frame conversion goes through
;; id's FULL resolution (target-segs) + the flat helpers, so it is correct whether
;; id is one clip or a scattered multi-clip proxy. Do NOT summarise a target to a
;; single [xs xe] span — that only holds for a single contiguous segment.
(defn- ref-len [scene ref] (length (target-segs scene ref)))
(defn- at->local
"Normalise a ref's :at (n<0 from the end) to a non-negative local frame."
[scene ref at]
(if (neg? at) (+ (ref-len scene ref) at 1) at))
(defn- at->src
"Source frame at ref point {:ref :at} — id's own local frame → source — or nil
if the ref dangles or the frame falls outside id."
[scene ref at]
(when-let [segs (seq (target-segs scene ref))]
(let [l (at->local scene ref at)]
(when (<= 0 l (length segs))
(local->source segs l)))))
(defn- src->at
"Target-local :at for source frame `src` within `ref` — source → id's own
local frame — or nil if outside id."
[scene ref src]
(some-> (seq (target-segs scene ref)) (source->local src)))
(defn- point-frame (defn- point-frame
"Resolve a point (living in group `gid`) to {:frame :track}, or nil if a ref "Resolve a point (living in group `gid`) to {:frame :track}, or nil if a ref
dangles." dangles."
@ -126,12 +141,8 @@
:track nil}) :track nil})
(map? point) (map? point)
(when-let [{:keys [xs xe track thumb thumb-start]} (target-range scene (:ref point))] (when-let [f (at->src scene (:ref point) (:at point))] ; nil if dangling / out of range
(let [n (:at point) {:frame f})))
f (if (neg? n) (+ xe n 1) (+ xs n))]
(when (<= xs f xe)
(cond-> {:frame f :track track}
thumb (assoc :thumb thumb :thumb-at (+ (or thumb-start 0) (- f xs))))))))) ; nil if trimmed out of range
(defn- rebase (defn- rebase
"Shift `sub`'s :local to start at 0 and stamp :mark = `id` (the mark now owns "Shift `sub`'s :local to start at 0 and stamp :mark = `id` (the mark now owns
@ -208,7 +219,7 @@
[scene gid] [scene gid]
(when-let [mid (first (broken-marks scene gid))] (when-let [mid (first (broken-marks scene gid))]
(let [ref (->> (:marks (grp scene gid)) (some #(when (= mid (:id %)) %)) :start :ref)] (let [ref (->> (:marks (grp scene gid)) (some #(when (= mid (:id %)) %)) :start :ref)]
(if (target-range scene ref) "reference trimmed away" "referenced clip deleted")))) (if (seq (target-segs scene ref)) "reference trimmed away" "referenced clip deleted"))))
;; --- what the current timeline is made of -------------------------------- ;; --- what the current timeline is made of --------------------------------
@ -287,6 +298,18 @@
(merge-bars (mapcat (fn [{[a b] :src}] (pieces ctx-segs a b)) ss)))))) (merge-bars (mapcat (fn [{[a b] :src}] (pieces ctx-segs a b)) ss))))))
vec)) vec))
(defn mark-extent
"EXACT context-local [lo hi] of mark `mark-id` of annotation `gid` — the true
min piece-start / max piece-end, WITHOUT merge-bars' floor/ceil. Endpoint
editing must use this (not a rounded lane bar) or the fixed end drifts a frame
per re-roll. `ctx-segs` = content-segments of ctx."
[scene ctx gid mark-id ctx-segs]
(let [pcs (->> (nested-src-marks scene ctx gid)
(filter #(= mark-id (:mark %)))
(mapcat (fn [{[a b] :src}] (pieces ctx-segs a b))))]
(when (seq pcs)
[(reduce min (map first pcs)) (reduce max (map second pcs))])))
(defn clip-loss? (defn clip-loss?
"True when annotation `gid` loses any content once clipped to its immediate "True when annotation `gid` loses any content once clipped to its immediate
parent annotation — it references frames outside the parent timeline, so it's parent annotation — it references frames outside the parent timeline, so it's
@ -303,14 +326,19 @@
(defn selection->marks (defn selection->marks
"Split local range [la lb) of context `ctx` into a run of single-clip ref "Split local range [la lb) of context `ctx` into a run of single-clip ref
marks, one per content segment it crosses (the 'no cross-clip marks' rule). marks, one per content segment it crosses (the 'no cross-clip marks' rule).
Each references the segment's source id with the right offsets." Each references the segment's source id with the right offsets. Offsets are
ROUNDED to whole frames here — this is where user frame-accuracy is applied
(clips stay exact for tiling; the mark snaps to a whole frame of its clip)."
[scene ctx la lb] [scene ctx la lb]
(mapv (fn [{:keys [mark src]}] (mapv (fn [{:keys [mark src]}]
(let [{:keys [xs]} (target-range scene mark) ;; :at is the target's OWN local frame (src->at), matching resolve-mark's
[a b] src] ;; slice — correct even when the target is a scattered multi-clip proxy.
;; The piece lies within one target segment, so its length maps 1:1.
(let [[a b] src
a' (src->at scene mark a)]
{:id (str (random-uuid)) ; string so it survives JSON {:id (str (random-uuid)) ; string so it survives JSON
:start {:ref mark :at (- a xs)} :start {:ref mark :at (js/Math.round a')}
:end {:ref mark :at (- b xs)}})) :end {:ref mark :at (js/Math.round (+ a' (- b a)))}}))
(slice (content-segments scene ctx) la lb))) (slice (content-segments scene ctx) la lb)))
(defn reconcile-run (defn reconcile-run
@ -372,12 +400,6 @@
[segs mid f] [segs mid f]
(some (fn [{m :mark [c _] :local}] (when (= m mid) (+ c (or f 0)))) segs)) (some (fn [{m :mark [c _] :local}] (when (= m mid) (+ c (or f 0)))) segs))
(defn- at->frame
"Normalise a ref's :at to a non-negative mark-time frame (resolves :at -1 etc.)."
[scene ref at]
(if (neg? at)
(let [{:keys [xs xe]} (target-range scene ref)] (+ (- xe xs) at 1))
at))
(defn- restore-mark [m] (defn- restore-mark [m]
;; keep :id keyworded in lockstep with the refs that target it: a nested ;; keep :id keyworded in lockstep with the refs that target it: a nested
@ -437,13 +459,18 @@
migrate))]))) migrate))])))
anns)) anns))
(defn display-point
"A ref-point {:ref :at} as a display cell {:seg :f}: the target id plus its OWN
local frame (context-local, whole-frame; not translated to the raw clip). The
shared basis for both the annotation editor rows and the jump popover, so they
can never disagree on how a point reads."
[scene {:keys [ref at]}]
{:seg ref :f (js/Math.round (at->local scene ref at))})
(defn- clip-row (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]}] [scene {:keys [start end]}]
{:s {:seg (:ref start) :f (js/Math.round (at->frame scene (:ref start) (:at start)))} {:s (display-point scene start) :e (display-point scene end)})
:e {:seg (:ref end) :f (js/Math.round (at->frame scene (:ref end) (:at end)))}})
(defn mark-row (defn mark-row
"One editor row for a mark. A plain clip/subclip ref collapses to {:s :e} "One editor row for a mark. A plain clip/subclip ref collapses to {:s :e}
@ -476,25 +503,25 @@
clip mark-group per clip (source range + track), and the root timeline. The 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 Clip ranges are kept EXACT (OTIO's fractional RationalTime), NOT rounded:
fractional RationalTime enters: a frame-based tool has no meaning below a whole adjacent clips must share their half-open boundary exactly to tile without a
frame, so we round once, at the source, and everything downstream (source gap, and independently rounding :start vs length breaks that. Frame-accuracy is
ranges, mark :at offsets, seeks, labels) stays frame-accurate by construction." applied where it belongs — at the mark the user creates (selection->marks
rounds :at) — and at display, never by rounding clips or bars."
[{:keys [duration tracks]}] [{:keys [duration tracks]}]
(let [r (fn [x] (js/Math.round x)) (let [vtracks (filter #(= :video (:kind %)) tracks)
vtracks (filter #(= :video (:kind %)) tracks)
track-map (into {} (map (fn [t] [(keyword (str "t" (:index t))) {:name (:name t)}])) vtracks) track-map (into {} (map (fn [t] [(keyword (str "t" (:index t))) {:name (:name t)}])) vtracks)
clips (into {} (for [t vtracks c (:clips t)] clips (into {} (for [t vtracks c (:clips t)]
[(keyword (:id c)) [(keyword (:id c))
{:type :clip :parent nil :name (:name c) {:type :clip :parent nil :name (:name c)
:start (r (:start c)) ; timeline position (frames) :start (:start c) ; timeline position (frames)
:marks [{:id (keyword (str (:id c) "-m")) :marks [{:id (keyword (str (:id c) "-m"))
:start (r (:media-in c)) :start (:media-in c)
:end (r (+ (:media-in c) (:duration c))) :end (+ (:media-in c) (:duration c))
:track (keyword (str "t" (:index t)))}]}]))] :track (keyword (str "t" (:index t)))}]}]))]
{:tracks track-map {:tracks track-map
:groups (assoc clips :root {:type :timeline :parent nil :groups (assoc clips :root {:type :timeline :parent nil
:marks [{:id :root-m :start 0 :end (r duration)}]})})) :marks [{:id :root-m :start 0 :end duration}]})}))
(defn clip-name [scene gid] (:name (grp scene gid))) (defn clip-name [scene gid] (:name (grp scene gid)))
@ -519,7 +546,7 @@
"Ref-point {:ref :at} for mark-time frame `f` within content-segment `seg` "Ref-point {:ref :at} for mark-time frame `f` within content-segment `seg`
(the convention selection->marks uses, so it resolves identically)." (the convention selection->marks uses, so it resolves identically)."
[scene {:keys [mark src]} f] [scene {:keys [mark src]} f]
{:ref mark :at (+ (- (first src) (:xs (target-range scene mark))) (or f 0))}) {:ref mark :at (+ (src->at scene mark (first src)) (or f 0))})
(defn- runs (defn- runs
"Contiguous runs of `gid`'s marks in `ctx`-local coords, each {:id :lo :len} "Contiguous runs of `gid`'s marks in `ctx`-local coords, each {:id :lo :len}
@ -551,7 +578,7 @@
[scene ctx gid] [scene ctx gid]
(mapv (fn [{:keys [id lo]}] (mapv (fn [{:keys [id lo]}]
(let [st (:start (some #(when (= id (:id %)) %) (:marks (grp scene gid))))] (let [st (:start (some #(when (= id (:id %)) %) (:marks (grp scene gid))))]
{:local lo :seg (:ref st) :f (js/Math.round (at->frame scene (:ref st) (:at st)))})) (assoc (display-point scene st) :local lo))) ; same cell as the editor row
(runs scene ctx gid))) (runs scene ctx gid)))
(defn linkables (defn linkables

View file

@ -471,12 +471,13 @@
(let [d (d-of e)] (let [d (d-of e)]
(when (or @moved? (> (js/Math.abs d) 3)) (when (or @moved? (> (js/Math.abs d) 3))
(reset! moved? true) (reset! moved? true)
;; round ONLY the dragged endpoint; the fixed endpoint stays
;; exact so selection->marks re-derives its mark identically.
(let [[la lb] (case mode (let [[la lb] (case mode
:move [(+ lo d) (+ hi d)] :move [(js/Math.round (+ lo d)) (js/Math.round (+ hi d))]
:start [(+ lo d) hi] :start [(js/Math.round (+ lo d)) hi]
:end [lo (+ hi d)])] :end [lo (js/Math.round (+ hi d))])]
(rf/dispatch [::events/reroll-proxy ann mark-id (rf/dispatch [::events/reroll-proxy ann mark-id la lb])))))
(js/Math.round la) (js/Math.round lb)])))))
up (fn up [_] up (fn up [_]
(.removeEventListener js/document "mousemove" move) (.removeEventListener js/document "mousemove" move)
(.removeEventListener js/document "mouseup" up) (.removeEventListener js/document "mouseup" up)
@ -624,6 +625,7 @@
thumbs @(rf/subscribe [::subs/thumbnails]) thumbs @(rf/subscribe [::subs/thumbnails])
authoring? (some? @(rf/subscribe [::subs/draft-group])) authoring? (some? @(rf/subscribe [::subs/draft-group]))
active-mark @(rf/subscribe [::subs/active-mark]) active-mark @(rf/subscribe [::subs/active-mark])
ctx @(rf/subscribe [::subs/context])
linking @(rf/subscribe [::subs/linking]) linking @(rf/subscribe [::subs/linking])
len @(rf/subscribe [::subs/length]) len @(rf/subscribe [::subs/length])
width (px len fps zoom) width (px len fps zoom)
@ -656,17 +658,17 @@
label]]))] label]]))]
[:div.ann-lanes {:style {:height lane-h :width width} [:div.ann-lanes {:style {:height lane-h :width width}
:on-mouse-down #(scrub! @content fps zoom %)} :on-mouse-down #(scrub! @content fps zoom %)}
;; annotation bars — solid for saved, dashed for the in-progress draft ;; VISUAL bars — one rectangle per piece (solid saved, dashed draft).
;; visible annotations: full bars with labels, greedily packed into lanes ;; Interaction is NOT here: a mark can span several pieces, so its
;; drag/handles live on ONE per-mark layer below (that's the fix for
;; handles-at-every-clip-boundary). Draft pieces are pointer-events
;; none so the per-mark layer receives the events.
(for [a visible (for [a visible
[j [lo hi mid]] (map-indexed vector (:bars a))] [j [lo hi mid]] (map-indexed vector (:bars a))]
^{:key (str (:id a) "-" j)} ^{:key (str (:id a) "-" j)}
[:div.ann-bar {:title (:name a) [:div.ann-bar {:title (:name a)
:on-mouse-down :on-mouse-down
(if (:draft a) (when-not (: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) (fn [e] (.stopPropagation e)
(if linking (if linking
(do (.preventDefault e) ; pick: link to this timeline (do (.preventDefault e) ; pick: link to this timeline
@ -680,25 +682,41 @@
:style {:top (+ 2 (* (lane-of (:id a)) 18)) :height 14 :style {:top (+ 2 (* (lane-of (:id a)) 18)) :height 14
:left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom)) :left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom))
:background (str (:color a) (if (:draft a) "44" "cc")) :background (str (:color a) (if (:draft a) "44" "cc"))
:cursor (if (:draft a) "grab" "pointer") :cursor (if (:draft a) "default" "pointer")
:pointer-events (when (:draft a) "none")
:border-radius 2 :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)") ;; per-MARK interaction layer (draft/edit only): each mark is ONE unit
:z-index (when (and mid (= mid active-mark)) 3)}} ;; spanning its whole extent — body-drag = move, click = draw, and
;; edge handles: drag to roll one endpoint (adds/drops clips as it ;; exactly two end-handles = resize (roll-proxy). No matter how many
;; crosses a boundary via roll-proxy). Only on the draft/edit mark. ;; visual pieces the mark has, it gets one handle pair, at its ends.
(when (:draft a) (for [a visible :when (:draft a)
(let [grip {:width 3 :height 9 :border-radius 2 mid (distinct (map #(nth % 2) (:bars a)))
:background "var(--paper)" :border "1px solid var(--ink)"} ;; EXACT context-local extent (never the rounded bar). The drag
zone {:position "absolute" :top 0 :width 9 :height "100%" :cursor "ew-resize" ;; keeps the FIXED endpoint at this exact value so its mark
:display "flex" :align-items "center" :justify-content "center"}] ;; re-derives identically — that's what stops the other end drifting.
[:<> :let [ext (scene/mark-extent scene ctx (:id a) mid segs)]
[:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :start lo hi fps zoom e)) :when ext
:style (assoc zone :left -3)} :let [[lo hi] ext
[:div {:style grip}]] active? (= mid active-mark)]]
[:div.bar-handle {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :end lo hi fps zoom e)) ^{:key (str "edit-" (:id a) "-" mid)}
:style (assoc zone :right -3)} [:div.mark-edit {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :move lo hi fps zoom e))
[:div {:style grip}]]]))]) :style {:position "absolute" :top (+ 2 (* (lane-of (:id a)) 18)) :height 14
:left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom))
:cursor "grab" :border-radius 2
:box-shadow (when active? "0 0 0 2px var(--ink)")
:z-index (if active? 4 2)}}
(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 ;; visible annotation labels
(for [a visible :let [[lo _] (first (:bars a))] :when lo] (for [a visible :let [[lo _] (first (:bars a))] :when lo]
^{:key (str "lbl-" (:id a))} ^{:key (str "lbl-" (:id a))}
@ -1173,7 +1191,10 @@
[:button.edit-btn {:title "Edit" :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"]]] [:button.edit-btn {:title "Edit" :on-click #(rf/dispatch [::events/edit-draft (:id a)])} "✎"]]]
[:div.ann (merge drag-props [:div.ann (merge drag-props
{:id (str "ann-" (name (:id a))) {:id (str "ann-" (name (:id a)))
:style {"--ann-color" (:color a)}}) ;; grey out an annotation with dangling marks (it sorts to the
;; bottom too) — still shown so its surviving marks stay usable
:style (cond-> {"--ann-color" (:color a)}
(:broken a) (assoc :opacity 0.55))})
[:div.ann-head [:div.ann-head
[:div.ann-title [:div.ann-title
[:span.ann-swatch {:style {:background (:color a)}}] [:span.ann-swatch {:style {:background (:color a)}}]
@ -1336,6 +1357,7 @@
live @(rf/subscribe [::subs/active-note-set]) live @(rf/subscribe [::subs/active-note-set])
active @(rf/subscribe [::subs/active-mark]) active @(rf/subscribe [::subs/active-mark])
rows (scene/marks->rows scene (:marks d)) rows (scene/marks->rows scene (:marks d))
broken (set (scene/broken-marks scene gid)) ; marks whose refs no longer resolve
valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d)))) valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d))))
save #(when valid? save #(when valid?
(rf/dispatch [::events/save-group gid (dissoc d :draft :gid) (rf/dispatch [::events/save-group gid (dissoc d :draft :gid)
@ -1391,6 +1413,7 @@
[:div.mark-block (merge (mark-drag-props put d i) [:div.mark-block (merge (mark-drag-props put d i)
{:class (str (when (= i (:src drag)) "dragging ") {:class (str (when (= i (:src drag)) "dragging ")
(when (= mark-id active) "active-mark ") (when (= mark-id active) "active-mark ")
(when (contains? broken mark-id) "broken ")
(when (and (:src drag) (= i (:over drag)) (when (and (:src drag) (= i (:over drag))
(not= i (:src drag))) "drop-before"))}) (not= i (:src drag))) "drop-before"))})
[:div.mark-row [:div.mark-row
@ -1399,6 +1422,8 @@
(.. e -dataTransfer (setData "text/mark-idx" (str i))) (.. e -dataTransfer (setData "text/mark-idx" (str i)))
(set! (.. e -dataTransfer -effectAllowed) "move") (set! (.. e -dataTransfer -effectAllowed) "move")
(reset! mark-drag {:src i}))} "⠿"] (reset! mark-drag {:src i}))} "⠿"]
(when (contains? broken mark-id)
[:span.ann-warn {:title "This mark's clip/reference no longer resolves"} "△ "])
;; a proxy collapses its cross-clip run to one read-only span ;; a proxy collapses its cross-clip run to one read-only span
;; (first-clip start → last-clip end); endpoint editing is via the ;; (first-clip start → last-clip end); endpoint editing is via the
;; lane handles (a later chunk). A plain clip mark stays editable. ;; lane handles (a later chunk). A plain clip mark stays editable.

View file

@ -294,18 +294,26 @@
(is (= #{:t0 :t1} (s/tracks segs))) (is (= #{:t0 :t1} (s/tracks segs)))
(is (= 200 (s/length segs))))))) (is (= 200 (s/length segs)))))))
(deftest from-otio-snaps-fractional-frames (deftest from-otio-keeps-clip-ranges-exact
(testing "OTIO's fractional RationalTime is rounded to whole frames at seed" (testing "clips keep OTIO's exact fractional ranges (so adjacent clips tile);
frame-accuracy is applied at mark creation, not here"
(let [parsed {:fps 24 :duration 199.6 (let [parsed {:fps 24 :duration 199.6
:tracks [{:index 0 :kind :video :name "W" :tracks [{:index 0 :kind :video :name "W"
:clips [{:id "t0-c0" :name "a" :start 0.2 :media-in 188.87 :duration 100.4}]}]} :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 (s/from-otio parsed) [:groups :t0-c0 :marks]))]
mark (first (get-in scene [:groups :t0-c0 :marks]))] (is (= 188.87 (:start mark))) ; exact, not rounded
(is (= 0 (get-in scene [:groups :t0-c0 :start]))) ; 0.2 -> 0 (is (= 289.27 (:end mark))))))
(is (= 189 (:start mark))) ; media-in 188.87 -> 189
(is (= 289 (:end mark))) ; 188.87+100.4=289.27 -> 289 (deftest selection-rounds-at-to-whole-frames
(is (= 200 (get-in scene [:groups :root :marks 0 :end]))) ; 199.6 -> 200 (testing "a selection over a clip with fractional layout still yields whole-frame
(is (every? integer? [(:start mark) (:end mark)]))))) :at offsets (clip stays exact, the mark snaps)"
(let [scene {:tracks {:t0 {:name "W"}}
:groups {:root {:type :timeline :parent nil :marks [{:id :m/r :start 0 :end 100}]}
:fr {:type :clip :parent nil :start 0.3 ; fractional timeline pos
:marks [{:id :m/fr :start 10.4 :end 110.4 :track :t0}]}}}
run (s/selection->marks scene :root 20 60)]
(is (seq run))
(is (every? integer? (mapcat (juxt #(get-in % [:start :at]) #(get-in % [:end :at])) run))))))
;; ========================================================================= ;; =========================================================================
;; Suite 4 — draft rows <-> marks (the two-input editor) ;; Suite 4 — draft rows <-> marks (the two-input editor)