feat: marks-first annotation authoring with proxy synthetic-clips

Invert the authoring flow around a new "proxy" mark-group and rework how
cross-clip selections are modelled, displayed, and edited. See
annotation_flow_plan.md for the chunked plan.

- scene: resolve a ref mark by slicing its target's resolved timeline in
  local frames, so a scattered-source proxy resolves piece-by-piece with the
  same {:ref g :at n} grammar as a clip (proxy = synthetic clip). Add
  make-proxy / roll-proxy / proxy-ref, proxy-aware mark-row/marks->rows, a
  :proxy restore branch, and per-mark lane-bars (distinct abutting marks no
  longer fuse into one bar).
- events/subs/views: selections now create a proxy + one collapsed mark and
  set an active-mark pointer; proxies persist alongside their annotation. A
  two-stage draft form (choosing -> creating): ranges + a single Title
  autocomplete that either associates with an existing annotation
  (transclusion) or creates a new one. Completing a new selection auto-enters
  drawing mode bound to the active mark.

Full test suite green (proxy resolve/roll, collapsed row, restore round-trip,
per-mark bars); app compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-07-05 02:08:17 -04:00
parent 23154ac284
commit b66d189a31
6 changed files with 485 additions and 68 deletions

View file

@ -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 <draft|annotation-id> :active-mark <mark-id>}`. 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 <ctx>` 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.

View file

@ -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]
{:db (-> 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)})))))
(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]}))))))

View file

@ -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 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."
[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)}])))
(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)]
(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))
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 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)
(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
(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 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."
[scene marks]
(mapv (fn [{:keys [start end]}]
(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))}})
marks))
(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 <pid> 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 `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 #(mark-row scene %) marks))
;; --- tiny view-state helpers (per-context playhead) ----------------------

View file

@ -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])]

View file

@ -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,6 +1274,8 @@
[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 %)))}]])])
(when-not choosing?
[:<>
[:div.form-marks-label "Content"]
^{:key gid} [content-editor gid (:content orig)]
(if (and linking (= gid (:gid linking)))
@ -1278,7 +1285,7 @@
[: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."])
[: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}))} "⠿"]
;; 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)]
[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)))))))} "✕"]]
: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 %])]]))
#(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,6 +1354,28 @@
: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."]
;; 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 %])
@ -1342,9 +1384,10 @@
[: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-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 ---------------------------

View file

@ -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
;; =========================================================================