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:
parent
213602c4cb
commit
06dd2b597c
6 changed files with 168 additions and 97 deletions
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue