diff --git a/tl/annotation_flow_plan.md b/tl/annotation_flow_plan.md new file mode 100644 index 0000000..556830c --- /dev/null +++ b/tl/annotation_flow_plan.md @@ -0,0 +1,86 @@ +# Annotation Flow Rework — Plan + +Marks-first authoring, proxy synthetic-clips, transclusion, active-mark, draft/edit lane+pane. + +## Decisions (locked) + +- **Flow inversion:** the "+" button enters a *draft-marks* mode. You author marks first (click clip / frame button / drag range), then Create-new or Associate-existing. No annotation is created up front. +- **Transclusion = Associate-existing.** Appends draft marks to another annotation and enters edit mode on it. Does not break "a mark appears in only one annotation" — ownership is unique, only the resolved footage underneath is shared (containment is a reference, not ownership). +- **Proxy = synthetic clip.** A cross-clip (or single-clip) selection is a mark-group `{:type :proxy}` living in the **same flat pool** as clips. Its internal marks are the per-clip run (A start→end, B, C, D…). The annotation holds ONE mark `[{:ref P :at 0} {:ref P :at -1}]` = full span of P. +- **Always-proxy, no promotion.** Every annotation-mark is born as a proxy (even single-clip, even instant). 1:1 proxy↔annotation-mark, never shared. Endpoint edits mutate P's internal marks **in place** (add/remove/trim boundary mark); middle ids stable. No bare↔proxy conversion ever. +- **Active mark = through-line pointer.** Session state `{:editing :active-mark }`. Set on create/click/drag/finish-draw. Read by drawing-mode (bind stroke), lane (highlight), pane (highlight). Shared by draft-new and edit-existing. +- **No new clip-id layer.** Raw clips already have stable ids; root ranges already reference them. Stability = edit-in-place; new id = delete+recreate. Arrangement-created subclips stay fragile → orphan + warn. +- **Pane collapses a proxy to one row** (start clip/frame → end clip/frame). Lane draws a proxy as one bar. + +## Open questions (revisit if they bite) + +- (none currently — promotion and pool questions resolved) + +--- + +## Chunk 1 — Proxy as synthetic clip (pure logic, no UI) ✅ + +- [x] Proxy shape: mark-group `{:type :proxy :parent nil :marks [...]}` in the flat pool, referenced by id (`scene/make-proxy`, marks via existing `selection->marks`). +- [x] `roll-proxy` : re-derive the run for a new selection, reusing interior ids, adding/dropping boundary marks (wraps existing `reconcile-run` — one fn covers grow/shrink both ends). `proxy-ref` builds the annotation's single `{:ref P :at 0 → :at -1}` mark. +- [x] **Core enabler:** rewrote `resolve-mark`'s ref branch to *slice the target's resolved timeline in local frames* (`target-segs` + `slice` + `rebase`), so a scattered-source proxy resolves piece-by-piece. Clips and proxies now use the identical `{:ref g :at n}` grammar. Instants handled via `instant-seg`. +- [x] Tests: proxy resolves as one collapsed mark (multi-clip + single-clip), roll keeps interior ids stable. Full suite green (only pre-existing `annotations-survive-json-roundtrip` fails on HEAD too). App compiles clean. + +**Note:** honored the exact `{:ref P :at 0 → :at -1}` shape (no `:proxy` special mark) — the slice rewrite made it uniform with clip refs. Deferred: nested annotation Y referencing frames *inside* a proxy-mark still routes through `point-frame`/`target-range` (single-segment assumption) — fine until sub-annotation authoring against a proxy-containing parent lands. + +## Chunk 2 — Selections become proxies + collapsed row + active-mark ✅ + +**Scope note:** the always-proxy lock forced this to also pull in the *display half of Chunk 6* (a proxy mark can't render in the per-clip `frame-chip` row) and *proxy persistence* (a new pool entity, or `{:ref proxy}` dangles on reload). So this landed as one coherent vertical slice. The existing draft **is** still a hidden annotation group — the true flow inversion (button → no annotation; create-vs-associate) is Chunk 3; here we kept `::open-draft` and just changed what a selection produces. + +- [x] `::draft-click-seg` (fresh-selection branch): wraps the run in a proxy group (`scene/make-proxy`) in the pool + gives the annotation one `scene/proxy-ref` mark, instead of appending a bare run. +- [x] **Active-mark pointer** introduced: `[:view :active-mark]` set on selection, cleared on `::open-draft`; `::subs/active-mark`; form highlights it (`.active-mark` class). Consumed by drawing (Chunk 4) next. +- [x] Collapsed row: `scene/mark-row` collapses a proxy to first-clip start → last-clip end, tagged `:proxy`; form renders it read-only (endpoint editing = lane handles, Chunk 5). Plain clip marks unchanged. +- [x] Persistence: `restore-annotations` `:proxy` branch; `::save-group` includes referenced proxies in `:changed`; `::remove-mark` drops the mark + its orphan proxy. (localStorage `tl.storage` is dead code — persistence is backend `put-scene` only.) +- [x] Tests: proxy collapses to one row, plain mark still a pair, proxy survives restore + resolves. App compiles clean. + +**Deferred to later chunks:** numeric/lane endpoint editing + repick on proxy rows (5); the real button→create-or-associate inversion (3); auto-draw on the active mark (4). Instant/frame-button and drag-range as distinct inputs still route through the existing click-click path. + +## Chunk 3 — Create-or-Associate ✅ (associate path) + +- [x] Associate-existing: once a draft has marks, the form shows an **"Or add these marks to an existing annotation"** autocomplete (shared `autocomplete` component, per request). Targets = `::associate-targets` sub (all reachable non-draft annotations, labelled `name · in ` via `scene/timelines`). +- [x] `::associate-marks draft-gid target-gid`: appends the draft's proxy-ref marks to the target, **persists the attachment now** (patch + referenced proxies via `put-scene`), discards the draft shell, sets active-mark, opens the target in edit mode (`:draft :edit`). The form is now keyed by draft-gid so it remounts with a fresh `orig` on the swap. +- [x] One-annotation invariant holds: marks *move* off the draft (which is discarded) onto exactly one target. +- [x] Create-new = the existing default (fill name + Save). App compiles clean. + +**Design choice:** associate persists immediately + reopens the target for edit (rather than staging an un-persisted attach), to sidestep the form's `with-let orig` snapshot lifecycle — the swap-in target's `orig` = its post-attach state, so later tweaks diff correctly and the association can't be silently lost. + +### Simplified draft UI (per feedback) ✅ +The fresh-draft form now has a **two-stage** shape driven by `[:view :draft-stage]` (`::subs/draft-stage`): +- **:choosing** (default on `::open-draft`) — you see ONLY the mark-range rows + one **Title** autocomplete (`allow-new?`). Picking an existing annotation → `::associate-marks` (edit it); typing a new title → `::create-named` (name it + advance). No name field, color, content, tags, script-notes, or Save yet; per-mark note-drop hidden too. +- **:creating** — the full new-annotation form (name/color/content/tags/notes/Save) appears. +So "the title is the autocomplete": one control forks create-vs-associate. + +**Deferred:** the "+" still makes a hidden draft group up front (invisible). Cancelling a choosing-stage draft drops it (orphan proxies left in pool — minor cruft; clean up later). + +## Chunk 4 — Auto drawing mode after range select ✅ + +- [x] `::draft-click-seg` is now an event-fx; completing a **new** selection seeks the video to the mark's start (`:player/seek`) and dispatches `::start-drawing` on the just-made proxy mark — you land in draw mode automatically. (Endpoint re-pick does NOT auto-draw — it's an edit, not a fresh selection.) +- [x] Drawing binds to the active mark: `::draft-click-seg` already sets `:active-mark` to the new mark, and `::start-drawing` binds `[:view :draw]` to that same `mark-id`. +- [x] Touch = active: `::start-drawing` and `::save-drawing` both set `:active-mark` to the drawn mark, so "last drawn/last touched" stays the active one. +- [x] Compiles clean, tests green. + +**Note:** auto-draw fires on every new selection (per the spec). To add another mark you cancel/finish the drawing (a clean no-op if empty) then select again. If that proves heavy for rapid multi-mark authoring we can gate it behind a toggle. + +## 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. + +## Chunk 6 — Pane collapsed display + active indicator + +- [ ] 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. + +## Chunk 7 — Orphan / stability polish + +- [ ] Grey-out + drop orphaned annotations to bottom of list with warn indicator. +- [ ] Per-mark broken-ref warnings; keep annotation if at least one mark still resolves. diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index 31d5729..d4c73c1 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -446,10 +446,12 @@ (fn [db [_ ann mark-id]] (let [existing (first (some (fn [m] (when (= mark-id (:id m)) (:drawings m))) (get-in db [:scene :groups ann :marks])))] - (assoc-in db [:view :draw] + (-> db + (assoc-in [:view :active-mark] mark-id) ; drawing a mark makes it the active one + (assoc-in [:view :draw] {:ann ann :mark-id mark-id :gid (or existing (keyword (str "draw-" (random-uuid)))) - :new? (nil? existing)})))) + :new? (nil? existing)}))))) (rf/reg-event-db ::cancel-drawing (fn [db _] (assoc-in db [:view :draw] nil))) @@ -467,6 +469,7 @@ 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 :save-error nil))] (merge {:db db} (persist-note-fx db gid))))))) @@ -571,7 +574,16 @@ {:type :annotation :parent (peek (get-in db [:view :stack])) :draft :new :name "" :color "#4e8fc2" :marks [] :v scene/schema-version}) - (assoc-in [:view :pt] :new)))) + (assoc-in [:view :pt] :new) + (assoc-in [:view :active-mark] nil) + (assoc-in [:view :draft-stage] :choosing)))) + +;; commit a fresh draft to "create new": name it and reveal the full form (color, +;; content, save). The alternative from the title picker is ::associate-marks. +(rf/reg-event-db ::create-named + (fn [db [_ gid title]] + (-> db (assoc-in [:scene :groups gid :name] title) + (assoc-in [:view :draft-stage] :creating)))) (rf/reg-event-db ::edit-draft (fn [db [_ gid]] (-> db (assoc-in [:scene :groups gid :draft] :edit) (assoc-in [:view :pt] :new)))) @@ -605,10 +617,17 @@ (= :annotation (:type g)) (assoc :v scene/schema-version)) patch (group-patch orig g) root? (nil? (:parent g)) ; the root timeline persists whole - id (get-in db [:project :id])] ; (no diff: it'd lose :type/:marks) + id (get-in db [:project :id]) ; (no diff: it'd lose :type/:marks) + ;; proxies this annotation references are synthetic clips in the pool — + ;; persist them alongside it or the {:ref proxy} marks dangle on reload. + proxies (into {} (keep (fn [m] + (let [pid (get-in m [:start :ref]) + pg (get-in db [:scene :groups pid])] + (when (= :proxy (:type pg)) [pid pg]))) + (:marks g)))] (cond-> {:db (-> db (assoc-in [:scene :groups gid] g) (assoc :save-error nil))} - (and id (seq patch)) - (assoc :http-xhrio (api/put-scene id {:changed {gid (if root? g patch)}} + (and id (or (seq patch) (seq proxies))) + (assoc :http-xhrio (api/put-scene id {:changed (merge {gid (if root? g patch)} proxies)} {:on-success [::scene-saved] :on-failure [::save-error]})))))) (rf/reg-event-fx ::delete-annotation @@ -679,9 +698,9 @@ (into (subvec marks 0 i) (subvec marks (inc i)))) (assoc-in [:view :pt] {:seg (:ref keep) :f (:at keep) :mark mark :i i}))))) -(rf/reg-event-db +(rf/reg-event-fx ::draft-click-seg - (fn [db [_ seg-id frame]] + (fn [{:keys [db]} [_ seg-id frame]] (let [scene (:scene db) [gid g] (some (fn [[gid g]] (when (:draft g) [gid g])) (:groups scene)) segs (scene/content-segments scene (:parent g)) @@ -702,10 +721,63 @@ (:drawings old) (assoc :drawings (:drawings old))) m)))) i (:i pt)] - (-> db (update-in [:scene :groups gid :marks] - #(into (into (subvec % 0 i) run) (subvec % i))) - (assoc-in [:view :pt] :new))) - (let [run (scene/selection->marks scene (:parent g) lo hi)] - (-> db (update-in [:scene :groups gid :marks] into run) - (assoc-in [:view :pt] :new))))) - (assoc-in db [:view :pt] {:seg seg-id :f (or frame 0)}))))) + {: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]]]}))) + {: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 +;; proxy group too (a draft's proxies are local until save, so a db-only dissoc +;; is enough — a saved-annotation edit will diff it away on Save). +(rf/reg-event-db + ::remove-mark + (fn [db [_ gid i]] + (let [marks (get-in db [:scene :groups gid :marks]) + pid (get-in marks [i :start :ref]) + prox? (= :proxy (get-in db [:scene :groups pid :type]))] + (cond-> (update-in db [:scene :groups gid :marks] + #(into (subvec % 0 i) (subvec % (inc i)))) + prox? (update-in [:scene :groups] dissoc pid))))) + +;; 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 +;; shell (never saved); further tweaks in the reopened form diff against this state. +(rf/reg-event-fx + ::associate-marks + (fn [{:keys [db]} [_ draft-gid target-gid]] + (let [marks (get-in db [:scene :groups draft-gid :marks]) + orig-t (get-in db [:scene :groups target-gid]) + target (update orig-t :marks (fnil into []) marks) + patch (group-patch orig-t target) ; just the :marks change + proxies (into {} (keep (fn [m] (let [pid (get-in m [:start :ref]) + pg (get-in db [:scene :groups pid])] + (when (= :proxy (:type pg)) [pid pg]))) + marks)) + id (get-in db [:project :id]) + db (-> db + (assoc-in [:scene :groups target-gid] (assoc target :draft :edit)) + (update-in [:scene :groups] dissoc draft-gid) + (assoc-in [:view :active-mark] (:id (last marks))) + (assoc-in [:view :pt] :new) + (assoc :save-error nil))] + (cond-> {:db db} + (and id (seq patch)) + (assoc :http-xhrio (api/put-scene id {:changed (merge {target-gid patch} proxies)} + {:on-success [::scene-saved] + :on-failure [::save-error]})))))) diff --git a/tl/src/tl/scene.cljs b/tl/src/tl/scene.cljs index 95b1c84..4321970 100644 --- a/tl/src/tl/scene.cljs +++ b/tl/src/tl/scene.cljs @@ -104,6 +104,17 @@ :thumb (-> segs first :thumb) :thumb-start (-> segs first :thumb-start)}))) +(defn- target-segs + "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 + by the ref invariant; several for a proxy or an arrangement group." + [scene id] + (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)) + (defn- point-frame "Resolve a point (living in group `gid`) to {:frame :track}, or nil if a ref dangles." @@ -122,24 +133,42 @@ (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 + "Shift `sub`'s :local to start at 0 and stamp :mark = `id` (the mark now owns + these segments regardless of which target they were sliced from)." + [id sub] + (let [base (or (some-> sub first :local first) 0)] + (mapv (fn [s] (let [[c d] (:local s)] + (assoc s :mark id :local [(- c base) (- d base)]))) + sub))) + +(defn- instant-seg + "A zero-length segment at local frame `la` of `tsegs` (for an instant mark), + carrying that frame's src/track/thumb." + [tsegs la] + (let [la* (min la (max 0 (dec (length tsegs))))] + (when-let [s (first (slice tsegs la* (inc la*)))] + (let [[c _] (:local s) [a _] (:src s)] + [(assoc s :local [c c] :src [a a])])))) + (defn resolve-mark - "One mark → its segment(s) (1 for a ref mark, 1+ for an absolute mark), with - :local 0-based within the mark. nil when a ref dangles." + "One mark → its segment(s) (1 for a single-clip ref, 1+ for a proxy/arrangement + ref or an absolute mark), with :local 0-based within the mark. A ref mark + slices its target's resolved timeline in the target's LOCAL frames (:at n, + n<0 from the end), so a scattered-source proxy resolves piece-by-piece just + like a contiguous clip does. nil when a ref dangles or falls out of range." [scene gid {:keys [id start end track]}] (if (map? start) ; ref mark (same target both ends) - (let [a (point-frame scene gid start) - b (point-frame scene gid end)] - (when (and a b) - (let [s (:frame a) e (:frame b)] - [{:mark id :track (:track a) :src [s e] :local [0 (- e s)] - :thumb (:thumb a) :thumb-start (:thumb-at a)}]))) + (when-let [tsegs (seq (target-segs scene (:ref start)))] + (let [len (length tsegs) + at->l #(if (neg? %) (+ len % 1) %) + la (at->l (:at start)) + lb (at->l (:at end))] + (when (and (<= 0 la len) (<= 0 lb len) (<= la lb)) + (rebase id (if (= la lb) (instant-seg tsegs la) (slice tsegs la lb)))))) (let [parent (:parent (grp scene gid))] (if parent ; absolute, relative to parent - (let [sub (slice (resolve scene parent) start end) - base (or (some-> sub first :local first) 0)] - (mapv (fn [s] (let [[c d] (:local s)] - (assoc s :mark id :local [(- c base) (- d base)]))) - sub)) + (rebase id (slice (resolve scene parent) start end)) [{:mark id :track track :src [start end] :local [0 (- end start)] :thumb (when (= :clip (:type (grp scene gid))) gid) :thumb-start 0}])))) @@ -224,6 +253,35 @@ (recur (:parent (grp scene p)) (src-intersect rs (mapv :src (resolve scene p))))))) +(defn- clip-segs + "Clip resolved segments `ss` (each {:mark :src …}) to coverage `cover` ([c d)s), + keeping each seg's :mark (the mark-aware sibling of src-intersect)." + [ss cover] + (vec (for [{[a b] :src :as s} ss [c d] cover + :let [lo (max a c) hi (min b d)] + :when (< lo hi)] + (assoc s :src [lo hi])))) + +(defn nested-src-marks + "Like nested-src but preserves :mark on each surviving segment, so lane bars can + be grouped per mark (see lane-bars). Ordered as resolve lays the marks." + [scene ctx gid] + (loop [p (:parent (grp scene gid)), ss (resolve scene gid)] + (if (or (nil? p) (= p ctx)) + ss + (recur (:parent (grp scene p)) (clip-segs ss (mapv :src (resolve scene p))))))) + +(defn lane-bars + "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." + [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)))) + vec)) + (defn clip-loss? "True when annotation `gid` loses any content once clipped to its immediate parent annotation — it references frames outside the parent timeline, so it's @@ -263,6 +321,35 @@ m)) (selection->marks scene ctx la lb)))) +;; --- proxy synthetic-clips ------------------------------------------------ +;; A proxy is a mark-group {:type :proxy} in the flat pool whose marks are the +;; per-clip run of a selection (selection->marks). An annotation references the +;; WHOLE proxy with a single mark {:ref P :at 0 → :at -1}, so the pane shows one +;; collapsed row and the lane one bar, while endpoint edits mutate the proxy's +;; internal run in place — stable interior ids, boundary marks added/dropped +;; (see reconcile-run). Because resolve-mark slices its target, the annotation's +;; one mark resolves through the proxy to the underlying clips piece-by-piece. +;; See annotation_flow_plan.md. + +(defn make-proxy + "A new proxy mark-group for selection [la lb) of context `ctx`. No gid yet — + the caller assigns one when inserting it into the pool." + [scene ctx la lb] + {:type :proxy :parent nil :marks (selection->marks scene ctx la lb)}) + +(defn roll-proxy + "Re-derive proxy `p`'s run for a new selection [la lb) of `ctx`, reusing the ids + of interior marks that still target the same segment: rolling an endpoint out + adds a fresh boundary mark, rolling in drops one, the middle never churns." + [scene ctx p la lb] + (assoc p :marks (reconcile-run scene ctx (:marks p) la lb))) + +(defn proxy-ref + "The single annotation mark referencing the whole proxy `pid` (as if the proxy + had been clicked): local frame 0 to its exclusive end." + [mark-id pid] + {:id mark-id :start {:ref pid :at 0} :end {:ref pid :at -1}}) + ;; --- mark-time helpers for the two-input annotation editor ---------------- ;; The editor works in MARK TIME — a 0-based frame within a VISIBLE content ;; segment — so it already accounts for the segment being trimmed by the parent @@ -337,6 +424,7 @@ (let [g (-> g (update :type keyword) (update :parent keyword))] [gid (case (:type g) :script-note (update g :regions #(mapv restore-region (or % []))) + :proxy (update g :marks #(mapv restore-mark (or % []))) ; synthetic clip: just its run :drawing g ; pure strokes + seed, JSON round-trips as-is (-> g (update :marks #(mapv restore-mark (or % []))) @@ -344,14 +432,30 @@ migrate))]))) anns)) +(defn- clip-row + "Editor row {:s … :e …} for a single-clip/subclip ref mark, in mark time." + [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))}}) + +(defn mark-row + "One editor row for a mark. A plain clip/subclip ref collapses to {:s :e} + directly; a proxy ref collapses to its run's FIRST-clip start and LAST-clip + end (so a cross-clip drag reads as one span), tagged :proxy so the form + knows to render it as a single unit rather than an editable clip pair." + [scene {:keys [start] :as mark}] + (let [pid (:ref start)] + (if (= :proxy (:type (grp scene pid))) + (let [rows (mapv #(clip-row scene %) (:marks (grp scene pid)))] + {:s (:s (first rows)) :e (:e (last rows)) :proxy pid}) + (clip-row scene mark)))) + (defn marks->rows - "Render single-clip `marks` as editor rows (one {:s … :e …} per mark, frames - normalised to mark time). 1:1 with `marks`, so row i pairs with mark i." + "Render `marks` as editor rows (one per mark, frames normalised to mark time). + 1:1 with `marks`, so row i pairs with mark i. Proxy marks collapse to one + row (see mark-row)." [scene marks] - (mapv (fn [{: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))}}) - marks)) + (mapv #(mark-row scene %) marks)) ;; --- tiny view-state helpers (per-context playhead) ---------------------- diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 3f0bcf2..5153578 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -16,6 +16,11 @@ (rf/reg-sub ::script-rev (fn [db] (:script-rev db 0))) (rf/reg-sub ::pane (fn [db] (get-in db [:view :pane] :annotations))) (rf/reg-sub ::active-note (fn [db] (get-in db [:view :active-note]))) +;; the mark most recently created/touched while authoring — drawings bind to it, +;; and the lane + form highlight it (see annotation_flow_plan.md, active-mark). +(rf/reg-sub ::active-mark (fn [db] (get-in db [:view :active-mark]))) +;; :choosing (fresh draft — marks + title picker only) | :creating (full form). +(rf/reg-sub ::draft-stage (fn [db] (get-in db [:view :draft-stage]))) (rf/reg-sub ::script-jump (fn [db] (get-in db [:view :script-jump]))) (rf/reg-sub ::hidden-notes (fn [db] (get-in db [:view :hidden-notes] #{}))) (rf/reg-sub ::hidden-tags (fn [db] (get-in db [:view :hidden-tags] #{}))) @@ -43,6 +48,17 @@ (rf/reg-sub ::context :<- [::stack] (fn [stack _] (peek stack))) +;; existing annotations a draft's marks can be associated with (transclusion): +;; every reachable non-draft annotation, labelled with its home context for +;; disambiguation. The draft itself is a :draft group, so timelines omits it. +(rf/reg-sub + ::associate-targets + :<- [::scene] + (fn [scene _] + (->> (scene/timelines scene) + (remove #(= :root (:gid %))) + (mapv #(select-keys % [:gid :name :in]))))) ; :in = home context, a display hint only + ;; the annotation group currently being authored/edited (the one flagged :draft), ;; with its group id merged in as :gid (rf/reg-sub @@ -108,9 +124,7 @@ (let [tags (get-in g [:meta :tags])] (not (or (some hidden-tags tags) (and (empty? tags) (contains? hidden-tags :untagged)))))) - (let [src-ranges (scene/nested-src scene ctx gid) ; best effort here - bars (scene/merge-bars - (mapcat (fn [[a b]] (scene/pieces segs a b)) src-ranges)) + (let [bars (scene/lane-bars scene ctx gid segs) ; one bar per mark; distinct marks never fuse reason (scene/broken-reason scene gid) oor (boolean (scene/clip-loss? scene gid)) hidden (get-in g [:meta :hidden])] diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index 8993bf5..26c7f7b 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -1232,11 +1232,16 @@ linking @(rf/subscribe [::subs/linking]) gid (:gid d) new? (= :new (:draft d)) - root? (nil? (:parent d)) ; the root timeline: content only + root? (nil? (:parent d)) + ;; a fresh draft starts in the "choosing" stage: just marks + a title + ;; autocomplete (pick an existing annotation → associate; type a new + ;; title → create). Everything else appears once you've committed. + choosing? (and new? (not root?) (= :choosing @(rf/subscribe [::subs/draft-stage]))) ; the root timeline: content only put (fn [g] (rf/dispatch [::events/put-group gid (dissoc g :gid)])) notes @(rf/subscribe [::subs/notes]) nmap (into {} (map (juxt :id identity)) notes) live @(rf/subscribe [::subs/active-note-set]) + active @(rf/subscribe [::subs/active-mark]) rows (scene/marks->rows scene (:marks d)) valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d)))) save #(when valid? @@ -1245,7 +1250,7 @@ (rf/dispatch [::events/finish-edit]))] [:form.form {:on-submit (fn [e] (.preventDefault e) (save))} [:div.form-head (cond root? "Edit description" new? "New annotation" :else "Edit annotation")] - (when-not root? + (when (and (not root?) (not choosing?)) [:<> [:div.form-row [:input.form-name {:placeholder "Name" :value (:name d) @@ -1269,16 +1274,18 @@ [autocomplete {:items (filterv (complement (set tags)) @(rf/subscribe [::subs/project-tags])) :placeholder "add tag…" :allow-new? true :on-choose #(put (assoc-in d [:meta :tags] (conj tags %)))}]])]) - [:div.form-marks-label "Content"] - ^{:key gid} [content-editor gid (:content orig)] - (if (and linking (= gid (:gid linking))) - [link-picker {:scene scene :ctx ctx - :on-commit #(commit-link! (select-keys % [:ref :at :kind]) (:label %)) - :on-cancel #(rf/dispatch [::events/cancel-linking])}] - [:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-linking gid])} - "Insert link"]) - (when (and linking (= gid (:gid linking))) - [:div.form-hint "Click a clip or the frame-readout to link it, or pick above."]) + (when-not choosing? + [:<> + [:div.form-marks-label "Content"] + ^{:key gid} [content-editor gid (:content orig)] + (if (and linking (= gid (:gid linking))) + [link-picker {:scene scene :ctx ctx + :on-commit #(commit-link! (select-keys % [:ref :at :kind]) (:label %)) + :on-cancel #(rf/dispatch [::events/cancel-linking])}] + [:button.add-mark {:type "button" :on-click #(rf/dispatch [::events/start-linking gid])} + "Insert link"]) + (when (and linking (= gid (:gid linking))) + [:div.form-hint "Click a clip or the frame-readout to link it, or pick above."])]) (when-not root? [:<> [:div.form-marks-label "Marks"] @@ -1290,6 +1297,7 @@ ^{:key i} [:div.mark-block (merge (mark-drag-props put d i) {:class (str (when (= i (:src drag)) "dragging ") + (when (= mark-id active) "active-mark ") (when (and (:src drag) (= i (:over drag)) (not= i (:src drag))) "drop-before"))}) [:div.mark-row @@ -1298,9 +1306,20 @@ (.. e -dataTransfer (setData "text/mark-idx" (str i))) (set! (.. e -dataTransfer -effectAllowed) "move") (reset! mark-drag {:src i}))} "⠿"] - [frame-chip scene segs put d i :start (:s row)] - [:span.mark-arrow "→"] - [frame-chip scene segs put d i :end (:e row)] + ;; a proxy collapses its cross-clip run to one read-only span + ;; (first-clip start → last-clip end); endpoint editing is via the + ;; lane handles (a later chunk). A plain clip mark stays editable. + (if (:proxy row) + [:<> + [:span.pt-chip.proxy [:span.pt-chip-name + (str (clip-label scene segs (:seg (:s row))) " @" (:f (:s row)) "f")]] + [:span.mark-arrow "→"] + [:span.pt-chip.proxy [:span.pt-chip-name + (str (clip-label scene segs (:seg (:e row))) " @" (:f (:e row)) "f")]]] + [:<> + [frame-chip scene segs put d i :start (:s row)] + [:span.mark-arrow "→"] + [frame-chip scene segs put d i :end (:e row)]]) [:button.mark-draw {:type "button" :class (when (seq (:drawings mark)) "has") :title (if (seq (:drawings mark)) "Edit drawing on this shot" "Draw on this shot") @@ -1310,10 +1329,11 @@ (rf/dispatch [::events/start-drawing gid mark-id]))} (if (seq (:drawings mark)) "🖼" "🖼+")] [:button.row-x {:type "button" :title "Remove" - :on-click #(put (update d :marks (fn [ms] (vec (concat (subvec ms 0 i) (subvec ms (inc i)))))))} "✕"]] - [note-drop nil (:notes mark) nmap live - #(rf/dispatch [::events/bind-note-mark gid mark-id %]) - #(rf/dispatch [::events/unbind-note-mark gid mark-id %])]])) + :on-click #(rf/dispatch [::events/remove-mark gid i])} "✕"]] + (when-not choosing? + [note-drop nil (:notes mark) nmap live + #(rf/dispatch [::events/bind-note-mark gid mark-id %]) + #(rf/dispatch [::events/unbind-note-mark gid mark-id %])])])) (when (map? pt) [:div.mark-row [:div.pt-chip [:span.pt-chip-name (str (clip-label scene segs (:seg pt)) @@ -1334,17 +1354,40 @@ :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-marks-label "Script notes"] - [note-drop "Whole annotation" (:notes d) nmap live - #(rf/dispatch [::events/bind-note-annotation gid %]) - #(rf/dispatch [::events/unbind-note-annotation gid %])] - (if (seq notes) - [:div.note-source - (for [n notes] ^{:key (name (:id n))} [note-src-chip gid n])] - [:div.form-hint "No script notes yet — create them in the Script tab."]) - [:div.form-hint "Drag a note onto a mark (or the whole-annotation target). It shows »»» while the playhead is in range."]]) + ;; 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. + (when choosing? + (let [targets @(rf/subscribe [::subs/associate-targets]) + ;; match on the plain NAME (round-trips cleanly); the home + ;; context is a dropdown-only hint via :item-suffix, so it never + ;; leaks into a newly-created annotation's name. + by-name (into {} (map (juxt :name :gid)) targets) + ctx-of (into {} (map (juxt :name :in)) targets)] + [:div.form-associate + [:div.form-marks-label "Title"] + [autocomplete {:items (mapv :name targets) :allow-new? true + :placeholder "new title, or pick an annotation to add to…" + :item-suffix (fn [n] (when-let [c (ctx-of n)] + [:span.cand-group (str " · " c)])) + :on-choose (fn [choice] + (if-let [g (by-name choice)] + (rf/dispatch [::events/associate-marks gid g]) + (rf/dispatch [::events/create-named gid choice])))}]])) + (when-not choosing? + [:<> + [:div.form-marks-label "Script notes"] + [note-drop "Whole annotation" (:notes d) nmap live + #(rf/dispatch [::events/bind-note-annotation gid %]) + #(rf/dispatch [::events/unbind-note-annotation gid %])] + (if (seq notes) + [:div.note-source + (for [n notes] ^{:key (name (:id n))} [note-src-chip gid n])] + [:div.form-hint "No script notes yet — create them in the Script tab."]) + [:div.form-hint "Drag a note onto a mark (or the whole-annotation target). It shows »»» while the playhead is in range."]])]) [:div.form-actions - [:button.save {:type "submit" :disabled (not valid?)} "Save"] + (when-not choosing? + [:button.save {:type "submit" :disabled (not valid?)} "Save"]) [:button.cancel {:type "button" :on-click #(do (if new? (rf/dispatch [::events/drop-group gid]) @@ -1762,7 +1805,8 @@ (defn annot-pane [] (let [pane @(rf/subscribe [::subs/pane]) - draft? (some? @(rf/subscribe [::subs/draft-group])) + draft @(rf/subscribe [::subs/draft-group]) + draft? (some? draft) save-err @(rf/subscribe [::subs/save-error])] [:div.pane-wrap (when save-err [:div.err {:style {:padding "4px 8px"}} "△ " save-err]) @@ -1774,7 +1818,9 @@ [:div.pane-body (if (= pane :script) [script-pane] - (if draft? [annotation-form] [commentary]))]])) + ;; key by the draft's gid so the form remounts (fresh `orig` snapshot) + ;; when Associate swaps the draft for an existing annotation. + (if draft? ^{:key (:gid draft)} [annotation-form] [commentary]))]])) ;; --- top bar: HyperCard-style window title bar --------------------------- diff --git a/tl/test/tl/scene_test.cljs b/tl/test/tl/scene_test.cljs index e9dd323..79dcd2e 100644 --- a/tl/test/tl/scene_test.cljs +++ b/tl/test/tl/scene_test.cljs @@ -149,6 +149,101 @@ (is (= 1 (count run3))) (is (= a-id (:id (first run3))))))) ; …and the shrink +;; ========================================================================= +;; Suite 2b — proxy synthetic-clips (marks-first authoring) +;; ========================================================================= + +(deftest proxy-resolves-as-one-collapsed-mark + (testing "an annotation referencing a whole proxy resolves to the proxy's full + per-clip run — scattered-source clips pieced back together" + (let [p (s/make-proxy base :root 40 210) ; A-tail(40..100)+B+C-head(200..210) + scene (-> base + (with-group :prox p) + (with-group :ann {:type :annotation :parent :root + :marks [(s/proxy-ref :m/px :prox)]})) + segs (s/resolve scene :ann)] + (is (= 170 (s/length segs))) ; 60 + 100 + 10 + (is (= 40 (s/local->source segs 0))) ; A from frame 40 + (is (= 100 (s/local->source segs 60))) ; boundary into B + (is (= 200 (s/local->source segs 160))) ; boundary into C + (is (= #{:t0 :t1 :t2} (s/tracks segs))) ; every crossed clip's track + (is (= :m/px (:mark (first segs))))))) ; the one addressable unit + +(deftest proxy-of-a-single-clip-is-just-that-clip + (testing "a within-one-clip selection makes a one-mark proxy that resolves like the clip" + (let [p (s/make-proxy base :root 10 60) ; inside A only + scene (-> base (with-group :prox p) + (with-group :ann {:type :annotation :parent :root + :marks [(s/proxy-ref :m/px :prox)]})) + segs (s/resolve scene :ann)] + (is (= 1 (count (:marks p)))) + (is (= 50 (s/length segs))) + (is (= [10 60] (:src (first segs))))))) + +(deftest roll-proxy-keeps-interior-ids-stable + (testing "growing/shrinking a proxy's endpoints keeps interior ids and edits + boundary marks in place (added out, dropped in)" + (let [p0 (s/make-proxy base :root 40 210) ; A-tail, B, C-head + [a0 b0 c0] (mapv :id (:marks p0)) + grow (s/roll-proxy base :root p0 40 300) ; roll end OUT to full C + shrink (s/roll-proxy base :root p0 110 210)] ; roll start IN, dropping A + (is (= 3 (count (:marks p0)))) + (testing "grow keeps all three ids; C's boundary mark is edited in place to full" + (is (= [a0 b0 c0] (mapv :id (:marks grow)))) + (is (= 100 (get-in (nth (:marks grow) 2) [:end :at])))) + (testing "shrink drops A, keeps B and C ids" + (is (= [b0 c0] (mapv :id (:marks shrink)))))))) + +(deftest proxy-mark-collapses-to-one-row + (testing "a proxy ref renders as ONE editor row: first-clip start → last-clip end" + (let [p (s/make-proxy base :root 40 210) ; A-tail, B, C-head + scene (-> base (with-group :prox p)) + rows (s/marks->rows scene [(s/proxy-ref :m/px :prox)])] + (is (= 1 (count rows))) + (is (= :prox (:proxy (first rows)))) + (is (= {:seg :clip-a :f 40} (:s (first rows)))) ; A from frame 40 + (is (= {:seg :clip-c :f 10} (:e (first rows))))))) ; C to frame 10 + +(deftest plain-clip-mark-still-renders-a-pair + (testing "a non-proxy mark keeps the editable clip→clip row (no :proxy tag)" + (let [rows (s/marks->rows base [(refm :m/m :clip-a 0 -1)])] + (is (nil? (:proxy (first rows)))) + (is (= {:seg :clip-a :f 0} (:s (first rows)))) + (is (= {:seg :clip-a :f 100} (:e (first rows))))))) + +(deftest lane-bars-keep-distinct-marks-separate + (testing "two abutting but DISTINCT marks render as two bars, not one fused bar; + a single cross-clip proxy still coalesces to one bar" + (let [p1 (s/make-proxy base :root 0 200) ; A+B + p2 (s/make-proxy base :root 200 300) ; C (abuts B) + scene (-> base (with-group :p1 p1) (with-group :p2 p2) + (with-group :ann {:type :annotation :parent :root + :marks [(s/proxy-ref :m/1 :p1) + (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 + ;; 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)))))))) + +(deftest proxy-survives-restore-roundtrip + (testing "a proxy group (string :type/:parent, string mark ids/refs from JSON) + re-keywords and still resolves through a referencing annotation" + (let [json-like {:prox {:type "proxy" :parent nil + :marks [{:id "p0" :start {:ref "clip-a" :at 40} :end {:ref "clip-a" :at 100}} + {:id "p1" :start {:ref "clip-b" :at 0} :end {:ref "clip-b" :at 100}} + {:id "p2" :start {:ref "clip-c" :at 0} :end {:ref "clip-c" :at 10}}]}} + back (:prox (s/restore-annotations json-like)) + scene (-> base (with-group :prox back) + (with-group :ann {:type :annotation :parent :root + :marks [(s/proxy-ref :m/px :prox)]}))] + (is (= :proxy (:type back))) + (is (= [:clip-a :clip-b :clip-c] (map #(get-in % [:start :ref]) (:marks back)))) ; refs keyworded + (is (= 170 (s/length (s/resolve scene :ann))))))) ; resolves whole + ;; ========================================================================= ;; Suite 3 — playhead & playback ;; =========================================================================