fix: share contiguous projections across annotation and selection views
This commit is contained in:
parent
4ae79b6422
commit
79a87403b5
9 changed files with 186 additions and 331 deletions
|
|
@ -20,7 +20,7 @@ Use idiomatic ClojureScript formatting with two-space indentation and aligned ma
|
|||
|
||||
## Testing Guidelines
|
||||
|
||||
`shadow-cljs.edn` includes `test` in `:source-paths`, but this repository currently has no committed test suite or npm test script. Add tests under `test/tl/` using matching namespace names such as `tl.otio-test`. When adding test support, include a runnable npm script and document the command here. Until then, verify changes with `npm run watch` for interactive behavior and `npm run release` before merging.
|
||||
Run `npm test` for pure model and re-frame integration tests under `test/tl/`. Verify compilation with `npm run release`. Model tests should cover ordered clip slices, repeated footage, contextual contiguity, and visibility edges; see `data_model.org`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
|
|
|
|||
|
|
@ -1,98 +0,0 @@
|
|||
# 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.
|
||||
- [x] Each lane bar is now `[lo hi mark-id]` (`lane-bars`); consumers that want only the range still destructure `[lo hi]`. Test updated.
|
||||
- [x] Click a draft/edit mark's bar → drawing mode for that mark (via `mark-drag!` — a no-move gesture is a click → `::start-drawing`, which also sets active).
|
||||
- [x] Drag whole mark left/right → `::reroll-proxy` with both endpoints shifted (`mark-drag!` `:move`).
|
||||
- [x] Edge handles resize (`.bar-handle` at each edge → `mark-drag!` `:start`/`:end`) → `::reroll-proxy` → `roll-proxy`: within-clip edits the boundary mark in place (id stable), across-boundary adds/drops a clip. Endpoints clamped to `[0,len]`, min width 1.
|
||||
- [x] Active-mark highlight in the lane (box-shadow ring + z-index on the bar whose mark = `::active-mark`).
|
||||
- [x] Shared look: draft & saved bars use the same `.ann-bar` (draft dashed/translucent, saved solid); pane proxy rows carry the same `.active-mark` highlight as the lane.
|
||||
|
||||
- [x] **Drag-to-select on the timeline** (`region-select!`): while authoring, drag across clips or empty track space → a live preview band → one selection (proxy + auto-draw). Click a clip still does the two-click flow (no-move → `on-click` fallback). This is the "drag a range" input deferred from Chunk 2.
|
||||
- [x] **Visible edge handles**: each draft bar edge shows a small paper/ink pill grip (was invisible ew-resize zone).
|
||||
- [x] **Draw mode tears down on form save/cancel** (`::finish-edit` clears `:draw`/`:active-mark`/`:pt`/`:draft-stage`) — no more stuck overlay.
|
||||
|
||||
**Rough edges (polish later):** very narrow bars (<~18px) are mostly handle. Numeric endpoint editing in the pane rows is still read-only (the lane is the editing surface).
|
||||
|
||||
## Chunk 6 — Pane collapsed display + active indicator
|
||||
|
||||
- [ ] 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 ✅
|
||||
|
||||
- [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).
|
||||
- [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).
|
||||
|
|
@ -1,42 +1,47 @@
|
|||
#+title: Data Model
|
||||
#+title: Data model
|
||||
|
||||
a mark-group is the fundamental datastructure of this app. the whole scene graph is composed of them. we use this same structure to represent the main timeline, the clips within the timeline, and the annotations on the timeline. a mark-group is just an ordered list of marks with a little bit of extra data hanging off them depending on the type. it also has a parent: the mark-group context it belongs to.
|
||||
A scene contains tracks and a flat map of groups. OTIO supplies immutable raw clips.
|
||||
Each clip has a track, source-frame offset, duration, and position in the root timeline.
|
||||
|
||||
a mark can represent an instant or a range of time, and that instant or range can be defined in terms of frames (relative to the current timeline on the timeline stack) or frames w/r/t to other mark-groups and these can be mixed and matched. each mark can also optionally specify a target video track (video tracks and clips are initially sourced from an initial otio file; the otio is only a seed -- it populates :tracks and the initial clip + timeline mark-groups once, then we never look at it again. :tracks is the only thing in the whole app that isn't a mark-group). thus, the main timeline is a mark group with one mark: the start and end timestamp. a clip which belongs to that main timeline is a mark group with one mark: the start and end timestamps (within the main timeline) AND a video track. but the track hangs off the mark, optionally, not the root of the clip. a clip is only different from an annotation in that one of its marks specifies a track (and, soon, :thumbnails), so an annotation could target a track too. clips are parentless: they're a flat pool, referenced by id, never owned. :parent is an annotation-only thing -- the authoring/visibility context, i.e. which timeline i was in when i made it. this dodges the whole knot: if clips had parents, expanding an annotation would have to make the clip a child of the annotation AND the main timeline AND every sub-annotation at once. containment is a reference, not ownership. an annotation is a mark group which conceptually represents a point or points of interest with optional commentary, though it looks not substantially different than a timeline or clip in the data structure, and really it's so flexible it could represent whole re-edits of clips. its marks can also be timestamps in the context of some timeline, or they can be the start and end frames of a given clip, defined relative to the clip, or defined relative to another annotation, or any combination thereof, in any amount, and with any combination of ranges and instants. an instant is just a range where start == end (length 0), no separate type. concatenation goes by length, so an instant adds no duration -- it's a marker at the current offset, drawn as a diamond instead of a bar.
|
||||
An annotation owns an ordered vector of marks. Each mark has a stable id and an
|
||||
ordered vector of parts:
|
||||
|
||||
because an annotation is just a mark-group, and a timeline is just a mark group, any annotation can be pushed onto the timeline-stack, replacing the main timeline. the clips within the marks in the mark group are laid end to end to form one continuous duration, a new timeline. the cool thing here is that you can now annotate within the context of this annotation. so if we are inside annotation A, annotation A is the :parent of our new Annotation B. if annotation B uses absolute timestamp marks, they are relative to the annotation A timeline, not the main timeline. and if annotation B uses clip based timestamps, they can only reference the frames of the underlying clip which are within range of the annotation (annotation A, the parent, may have start half way through the clip at the beginning, and end half way through the clip at the end). and then you can push annotation B onto the timeline stack, annotate within that, and on and on.
|
||||
#+begin_src clojure
|
||||
{:type :annotation
|
||||
:in [:root :chapter]
|
||||
:marks [{:id :moment
|
||||
:parts [{:clip :clip-a :start 10 :end 20}
|
||||
{:clip :clip-c :start 30 :end 50}
|
||||
{:clip :clip-a :start 10 :end 20}]}]}
|
||||
#+end_src
|
||||
|
||||
something about mark groups to note is that marks need not be defined in order. if the main timeline has clip A, B, and C laid end to end, an annotation X can have marks [[clipC[0], clipC[-1]], [clipA[0], clipA[-1]], [clipB[0], clipB[-1]]. -1 represents last available frame of the clip (note here that available frame may differ from absolute last frame of the underlying clip, because the annotation context we're in could cut off half the clip, for example). note this clamping only bites for raw clip refs across a trim -- if you reference the subclip (the parent's mark) instead, the trim is baked into the mark's range, so subclip[-1] = the subclip's own end, no clamp (more below). in this example, we have totally rearranged the clips into a timeline B C A, end to end. and you can also imagine we can cut clips in half, interleave them, repeat them and so on.
|
||||
A part is a half-open integer frame range within a raw clip. Repeats, trims, and
|
||||
order are explicit. Marks can bind notes and drawings. Those bindings belong to
|
||||
the mark, not to any one rendered occurrence.
|
||||
|
||||
hmm here's a struggle though. let's say i have interleaved half of A with half of B in an annotation which i pushed onto the timeline stack:
|
||||
Opening an annotation concatenates its parts, in mark order. Selecting footage
|
||||
in any timeline stores raw-clip parts immediately. Editing or deleting the context
|
||||
does not change the selected footage.
|
||||
|
||||
marks: [[clipB[20], clipB[40]], [clipA[0], clipA[20]], [clipB[0], clipB[20]] [clipA[20], clipA[40]]]
|
||||
=:in= is a set of visibility edges serialized as a vector; order has no meaning.
|
||||
Moving replaces the source edge with the destination edge. Linking adds an edge.
|
||||
Associating new marks with an annotation also enables its edge in the context
|
||||
where the marks were selected. Other edges remain untouched. An annotation with
|
||||
no surviving contexts is listed at root so it can be filed again.
|
||||
|
||||
we want to be able to mark these sub clips independently for a new annotation Y, right? but these are just 2 root clips that became 4. so how can we define annotation Y with respect to any of these 4 clips? we don't want to use absolute timeline time, but we also don't want to use absolute clip time because our marks can cross between the first sub clip (b 20 - 40) and the second (a 0 to a 20), so clip time means nothing here. we also need to remember that the parent timeline can clear out its marks at will. so we can definitely orphan annotations - that's ok, that's a UI concern we can display warnings for and just grey out basically (drop to bottom of annotation list, for example, with a warn emoji and let the user edit to specify its marks again, even warn on which marks are broken, and if at least one mark is still valid still display it there). so it rly seems like an annotation SHOULD create synthetic subclips which point at the raw clips so that we can use the synthetic subclips as our targets. but they need to be stable identities, serializable/deserializable.
|
||||
Compilation and projection are different operations. =resolve= produces ordered
|
||||
source segments; =project-bars= finds every matching raw-clip occurrence in the
|
||||
render context, intersects ranges, and merges adjacent/overlapping display pieces.
|
||||
The same projection drives lane bars, selected bars, editor summaries, and bindings.
|
||||
Different marks never merge into one editable mark. Repetition in the context can
|
||||
make one stored part appear several times.
|
||||
|
||||
resolved: every mark gets a stable id (a uuid) at creation, and a clip/subclip-ref mark stays within ONE clip -- so the addressable subclip is just a single-clip mark, addressed by mark-id alone. a selection that crosses clip boundaries is stored as a RUN of per-clip marks (the lane sticks the contiguous run into one bar; the editor splits/merges at boundaries on edit). a mark is already a recursive structure pointing at a raw clip, the id just makes it addressable, so no separate entities, no recreation lifecycle. Y references A's subclips by mark-id: {:ref <mark-id> :at n}.
|
||||
- edit a mark's range -> same id -> Y follows it
|
||||
- reorder marks -> ids travel with them -> Y follows the content, not the slot
|
||||
- delete a mark -> id gone -> Y dangles -> orphan (grey out, warn per broken mark, keep if at least one still resolves)
|
||||
- add a mark -> new id
|
||||
so "keep identity unless the whole thing is different" isn't an algorithm we run, it's a ui affordance: editing a row in place keeps the id, delete-row + add-row makes a new id. like keyed list editing / db rows with primary keys -- you carry stable keys, you never diff structure to guess identity.
|
||||
Projection cannot be inverted: a bounding range loses gaps, order, and repetition.
|
||||
The editor displays each contiguous projected range separately. Expanding a mark's
|
||||
summary exposes its actual ordered parts for endpoint edits. Endpoint edits clamp
|
||||
within that clip and cannot cross the opposite endpoint. The projected display
|
||||
never reconstructs or replaces the stored parts. Annotation drag-and-drop changes
|
||||
visibility only; dragging on the timeline creates a new selection.
|
||||
|
||||
and the subclip bakes the trim into its own definition, so it's a clean map to source: subclip[n] = src-start + n, subclip[-1] = src-end, no clamp, no context lookup. that makes resolving a ref context-free: (mark-id, scene) -> source range, no timeline-stack needed. the stack only decides which context's local timeline you're looking at, not how a ref resolves. only absolute (bare number) points are context-dependent -- they're local frames of the context the mark lives in.
|
||||
|
||||
so the whole mark grammar is two point kinds:
|
||||
- ref point {:ref <mark-id> :at n} -> frame n of that mark's resolved range (context-free, n negative = from the end)
|
||||
- absolute point <number> -> a local frame of the context the mark lives in (resolved through that context's spans)
|
||||
mix them in a single range, instant = start==end, track optional on the mark. a raw clip is just a mark whose range is full source + a track. a clip/subclip-ref mark keeps both endpoints on the SAME target, so it resolves to exactly one source segment; only an absolute mark may span several.
|
||||
|
||||
decided: a clip/subclip-ref mark never crosses a clip boundary, so the "range whose endpoints are in different subclips" case just can't happen. a selection across clips is a run of single-clip marks instead, one per clip, laid end to end (genuinely contiguous -- the lane only draws them as one bar). this keeps resolve trivial (one source segment per ref mark), makes every piece addressable by mark-id alone, and makes a reorder follow each piece independently instead of swelling. the merge/unmerge is localized: dragging a boundary WITHIN a clip edits that end mark in place (id stable); dragging ACROSS a clip boundary adds/removes a whole clip from the selection (creates/destroys that end mark); the fully-contained middle clips never churn. the same rule applies at any depth -- "clip boundary" means a boundary in the fully-resolved footage, so it works the same whether you're at root crossing raw clips or nested crossing a parent mark's segments. the one multi-segment mark left is an absolute one (bare-number local range): it's arrangement-relative, you build it by dragging the timeline rather than by referencing, and it resolves via slice.
|
||||
|
||||
|
||||
this brings us to playing. since right now there is only one source video file, we need to be able to seek to arbitrary frames. each context (mark-group) keeps its own local playhead, used when it's the top of the timeline stack. when we hit play, in the example above of annotation X, we find the clip under the playhead, compute the source frame, seek there, and start playing. one correction though: the local playhead has to be the master clock, not the video. you can't derive local position from currentTime -- once an annotation repeats or reorders clips, one source frame maps to several local frames, it's not invertible. so the local playhead advances on its own (wall-clock x fps while playing), and every frame we compute expected = group->media(local) and seek the video there only if round(currentTime*fps) != expected. within a clip, expected tracks the video's natural playback so no seek fires; at a mark boundary it jumps once and we seek. and right -- no recursion at play time: we resolve the current context once into flat ordered spans, and group->media is just the flat lookup the renderer already does.
|
||||
# how do we determine which tracks are included when we zoom into each annotation? for now it should just be if a clip is within the ranges of the mark-group, its track is included in the annotation.
|
||||
# automatically scroll to bottom-most track in mark group range when we hit the start mark? but what if it's massively spread out. maybe not then. scrolling should be an option turned on. thats ok. make it explicit.
|
||||
|
||||
|
||||
* update!
|
||||
- ok so the idea is this. you hit the new annotation button. it does not auto-select a mark for you. you can either click the clip, click the frame button, or drag a range. after you select a range, you are automatically in drawing mode. your drawings are connected to the mark, not the annotation. a mark should only ever appear in one annotation. instead of creating a new annotation mark group by default, this mode also allows you to either create new or associate with an existing annotation. associate with existing gives you our dropdown with only other annotations available. when you pick one, you effectively go into "edit" mode on that annotation with the new marks suddenly added. so this is basically our "transclusion": we can have annotations with marks embedded in other timelines. this is great for if we have subdivided our analysis into "chapters" but want to annotate shared concepts across them while keeping the main annotation pane clear. it's organized. so the big thing is that you don't create the annotation, you create the mark(s) first, then either create or assoc the annotation.
|
||||
- another crucial thing: if we click and drag and it spans multiple clips, the range we have in our create/add annotation UI in the annotation pane should only show the start and end points relative to the clips at start and end. so the way this will work is we will create a mark group that's not an annotation as our "proxy" marks so that they don't appear in the ui that spans the full range and contains the full sequential clips, and then the mark group on the annotation that contains that mark group will just use that mark group as start and end as if it had been clicked. so, for example: there's clips A, B, C and D contiguous. user drags region from clip A to clip D. in the UI, we should see that our mark starts at clip A frame 0 and ends at clip D last frame, so we need to "pass through" the synthetic unnamed non-annotation mark group to the underlying clips, the synthetic unnamed non-annotation mark group is a proxy.. so that proxy mark group has marks that go from clip A start-clip A end, clip B start - clip B end, clip C start to clip C end, and clip D start to clip D end. makes sense? so what do we do if the user wants to drag adjust endpoint in UI? let's say there's another clip before clip A called clip 0. we move start point BACK to clip 0 frame 50. well, our main mark group just shows the range as we would expect: clip 0 frame 50 TO clip D last frame. but the proxy mark group? it has a new mark range with new mark id at the beginning, but the other mark ids are stable. and the same is true of rolling the end point forward: new mark id, new range, rest are stsable. what if we roll the endpoints inward? same principle but we kill off mark ranges instead of adding new ones. should be clean. so this means we needs we need to change how draft/edit marks look/act in the lane. when in draft/edit mode, clicking on the mark brings up drawing mode for that mark (there can still be a button next to the mark in the edit pane). you can drag the whole mark left to right. and you can also grab handles on the edges of the annotation left to right. and since we consider whatever last created or last touched mark to be the "active" one for associating drawings to, we need to have that visually represented in the timeline, and in the pane where the draft marks or edit annotation is. and these need to share the same look for the mark range,s its only the stuff above it that iwll change. make sense?
|
||||
- note: when we talk about rolling the whole clip around, we know that the mark ids are going to change if we highlight one clip, unhighlight, then return back. this means that if we annotated a range defined w/r/t that annotation, the underlying gids are broken forever, even if they're rolled back. so that they exist still, right? since the underlying clips will never change, i wonder if we could just give each clip a stable identifier and define our root-most ranges in terms of those stable identifiers? or is that worse? idk
|
||||
View state holds navigation history, per-context playheads, and the active editing
|
||||
context. None of these is a parent or an input to the meaning of stored marks.
|
||||
|
|
|
|||
|
|
@ -766,7 +766,8 @@
|
|||
|
||||
(defn- file-edge [db gid target add? source]
|
||||
(let [g (get-in db [:scene :groups gid])
|
||||
edges (if add? (:in g) (remove #{source} (:in g)))
|
||||
edges (scene/membership (:scene db) gid)
|
||||
edges (if add? edges (disj edges source))
|
||||
next-edges (vec (distinct (cond-> (vec edges) target (conj target))))
|
||||
db (update db :view dissoc :dragging-ann :dragging-ann-source)]
|
||||
(if (and (= :annotation (:type g)) (not (:draft g)) (not= gid target)
|
||||
|
|
@ -795,14 +796,6 @@
|
|||
(fn [db [_ failure]]
|
||||
(assoc db :save-error (api/error-message failure "Couldn't save your changes — they're unsaved."))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::unset-endpoint
|
||||
(fn [db [_ gid mark-id which]]
|
||||
(let [segs (scene/content-segments (:scene db) (get-in db [:view :edit-context]))
|
||||
[lo hi] (scene/mark-extent (:scene db) gid mark-id segs)]
|
||||
(assoc-in db [:view :pt] {:mark-id mark-id :which which
|
||||
:keep (if (= which :start) hi lo)}))))
|
||||
|
||||
(defn- select-range-fx [db gid g lo hi]
|
||||
(let [ctx (get-in db [:view :edit-context])
|
||||
mark (scene/make-mark (:scene db) ctx lo hi)
|
||||
|
|
@ -828,21 +821,12 @@
|
|||
(fn [{:keys [db]} [_ seg-id frame]]
|
||||
(let [scene (:scene db)
|
||||
[gid g] (some (fn [[gid g]] (when (:draft g) [gid g])) (:groups scene))
|
||||
ctx (get-in db [:view :edit-context])
|
||||
segs (scene/content-segments scene ctx)
|
||||
segs (scene/content-segments scene (get-in db [:view :edit-context]))
|
||||
pt (get-in db [:view :pt])]
|
||||
(if (map? pt)
|
||||
(let [a (if (:mark-id pt) (:keep pt) (scene/seg-local segs (:seg pt) (:f pt)))
|
||||
b (scene/seg-local segs seg-id (or frame (scene/seg-length segs seg-id)))
|
||||
lo (min a b) hi (max a b)]
|
||||
(if-let [mid (:mark-id pt)]
|
||||
{:db (-> db
|
||||
(update-in [:scene :groups gid :marks]
|
||||
(fn [marks] (mapv #(if (= mid (:id %))
|
||||
(assoc % :parts (scene/selection->parts scene ctx lo hi)) %)
|
||||
marks)))
|
||||
(assoc-in [:view :pt] :new))}
|
||||
(select-range-fx db gid g lo hi)))
|
||||
(let [a (scene/seg-local segs (:seg pt) (:f pt))
|
||||
b (scene/seg-local segs seg-id (or frame (scene/seg-length segs seg-id)))]
|
||||
(select-range-fx db gid g (min a b) (max a b)))
|
||||
{:db (assoc-in db [:view :pt] {:seg seg-id :f (or frame 0)})}))))
|
||||
|
||||
(rf/reg-event-db ::remove-mark
|
||||
|
|
@ -851,29 +835,9 @@
|
|||
#(into (subvec % 0 i) (subvec % (inc i))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::resize-mark
|
||||
(fn [db [_ gid mid la lb]]
|
||||
(let [scene (:scene db)
|
||||
ctx (peek (get-in db [:view :stack]))
|
||||
len (scene/length (scene/content-segments scene ctx))
|
||||
lo (max 0 (min (scene/assert-frame "range start" la) (dec len)))
|
||||
hi (max (inc lo) (min (scene/assert-frame "range end" lb) len))]
|
||||
(update-in db [:scene :groups gid :marks]
|
||||
(fn [marks] (mapv #(if (= mid (:id %))
|
||||
(assoc % :parts (scene/selection->parts scene ctx lo hi)) %)
|
||||
marks))))))
|
||||
|
||||
(rf/reg-event-db
|
||||
::set-mark-frame
|
||||
(fn [db [_ gid mid which frame]]
|
||||
(update-in db [:scene :groups gid :marks]
|
||||
(fn [marks]
|
||||
(mapv (fn [m]
|
||||
(if (= mid (:id m))
|
||||
(let [i (if (= which :start) 0 (dec (count (:parts m))))]
|
||||
(assoc-in m [:parts i which]
|
||||
(scene/assert-frame "endpoint frame" frame)))
|
||||
m)) marks)))))
|
||||
::set-part-frame
|
||||
(fn [db [_ gid mid index which frame]]
|
||||
(update-mark db gid mid #(scene/set-part-frame (:scene db) % index which frame))))
|
||||
|
||||
;; Adding footage and making it visible in the authoring context is one edit.
|
||||
(rf/reg-event-fx
|
||||
|
|
|
|||
|
|
@ -42,17 +42,6 @@
|
|||
segs)
|
||||
(when (seq segs) (-> segs last :src second))))
|
||||
|
||||
(defn source->local
|
||||
"Local frame for source frame `sf` (first segment containing it), or nil."
|
||||
[segs sf]
|
||||
(assert-frame "source frame" sf)
|
||||
(some (fn [{:keys [src local]}]
|
||||
(assert-range "segment source" src)
|
||||
(assert-range "segment local" local)
|
||||
(let [[a b] src [c _] local]
|
||||
(when (and (<= a sf) (< sf b)) (+ c (- sf a)))))
|
||||
segs))
|
||||
|
||||
(defn pieces
|
||||
"Where source range [sa sb) lands in local coords: a list of [lo hi)."
|
||||
[segs sa sb]
|
||||
|
|
@ -154,10 +143,6 @@
|
|||
(project-bars (resolve-mark scene gid mark) context)))
|
||||
(get-in scene [:groups gid :marks]))))
|
||||
|
||||
(defn mark-extent [scene gid mid context]
|
||||
(let [bars (mark-bars scene gid mid context)]
|
||||
(when (seq bars) [(ffirst bars) (second (last bars))])))
|
||||
|
||||
(defn broken-marks [scene gid]
|
||||
(->> (get-in scene [:groups gid :marks])
|
||||
(filter #(or (empty? (:parts %))
|
||||
|
|
@ -167,8 +152,12 @@
|
|||
(defn broken-reason [scene gid]
|
||||
(when (seq (broken-marks scene gid)) "missing clip or invalid range"))
|
||||
|
||||
(defn membership [scene gid]
|
||||
(set (get-in scene [:groups gid :in])))
|
||||
(defn membership
|
||||
"Unfiled annotations appear at root, including when their last context is deleted."
|
||||
[scene gid]
|
||||
(let [edges (filter #(contains? #{:annotation :timeline} (get-in scene [:groups % :type]))
|
||||
(get-in scene [:groups gid :in]))]
|
||||
(if (seq edges) (set edges) #{:root})))
|
||||
|
||||
(defn child-of? [scene ctx gid]
|
||||
(contains? (membership scene gid) ctx))
|
||||
|
|
@ -206,11 +195,15 @@
|
|||
[groups]
|
||||
(update-vals groups restore-group))
|
||||
|
||||
(defn marks->rows [_ marks]
|
||||
(mapv (fn [{:keys [parts]}]
|
||||
{:s {:seg (:clip (first parts)) :f (:start (first parts))}
|
||||
:e {:seg (:clip (last parts)) :f (:end (last parts))}})
|
||||
marks))
|
||||
(defn marks->rows [scene ctx marks]
|
||||
(let [segments (content-segments scene ctx)]
|
||||
(mapv #(project-bars (resolve-mark scene nil %) segments) marks)))
|
||||
|
||||
(defn set-part-frame [scene mark index which frame]
|
||||
(let [{:keys [clip start end]} (get-in mark [:parts index])
|
||||
[lo hi] (if (= which :start) [0 end] [start (get-in scene [:groups clip :duration])])]
|
||||
(assoc-in mark [:parts index which]
|
||||
(max lo (min hi (assert-frame "endpoint frame" frame))))))
|
||||
|
||||
(defn playhead [view ctx] (get-in view [:playheads ctx] 0))
|
||||
(defn set-playhead [view ctx frame] (assoc-in view [:playheads ctx] frame))
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
(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).
|
||||
;; and the lane + form highlight it.
|
||||
(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])))
|
||||
|
|
@ -53,9 +53,7 @@
|
|||
|
||||
(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.
|
||||
;; Any non-draft annotation can receive marks from the current context.
|
||||
(rf/reg-sub
|
||||
::associate-targets
|
||||
:<- [::scene]
|
||||
|
|
|
|||
|
|
@ -466,42 +466,13 @@
|
|||
(.addEventListener js/document "mousemove" move)
|
||||
(.addEventListener js/document "mouseup" up)))
|
||||
|
||||
(defn- mark-drag!
|
||||
"Pointer drag for a draft/edit mark's lane bar. `mode` = :move (whole mark) |
|
||||
:start | :end (one edge). Converts the clientX delta to a frame delta and
|
||||
rerolls the proxy live; a gesture that never moves past threshold is treated
|
||||
as a plain click → draw on the mark. `lo`/`hi` = the mark's current extent."
|
||||
[ann mark-id mode lo hi fps zoom ev]
|
||||
(.stopPropagation ev) (.preventDefault ev)
|
||||
(let [start-x (.-clientX ev)
|
||||
moved? (atom false)
|
||||
d-of (fn [e] (* (/ (- (.-clientX e) start-x) zoom) fps))
|
||||
move (fn [e]
|
||||
(let [d (d-of e)]
|
||||
(when (or @moved? (> (js/Math.abs d) 3))
|
||||
(reset! moved? true)
|
||||
(let [[la lb] (case mode
|
||||
:move [(frame-index "drag start frame" (+ lo d))
|
||||
(frame-index "drag end frame" (+ hi d))]
|
||||
:start [(frame-index "drag start frame" (+ lo d)) hi]
|
||||
:end [lo (frame-index "drag end frame" (+ hi d))])]
|
||||
(rf/dispatch [::events/resize-mark ann mark-id la lb])))))
|
||||
up (fn up [_]
|
||||
(.removeEventListener js/document "mousemove" move)
|
||||
(.removeEventListener js/document "mouseup" up)
|
||||
(when-not @moved? ; a click, not a drag → draw
|
||||
(goto! lo true)
|
||||
(rf/dispatch [::events/start-drawing ann mark-id])))]
|
||||
(.addEventListener js/document "mousemove" move)
|
||||
(.addEventListener js/document "mouseup" up)))
|
||||
|
||||
;; live drag-to-select region on the timeline (context-local [lo hi]) while
|
||||
;; authoring, or nil. Deref'd in the timeline render to draw the preview band.
|
||||
(defonce ^:private region-sel (r/atom nil))
|
||||
|
||||
(defn- region-select!
|
||||
"On the timeline while authoring: DRAG to select a region [lo hi) → one
|
||||
selection (a proxy). A plain CLICK (no drag) calls `on-click` — clicking a clip
|
||||
mark. A plain CLICK (no drag) calls `on-click` — clicking a clip
|
||||
picks it (draft-click-seg), clicking empty timeline just moves the playhead —
|
||||
so both still work in marking mode. `content` is the coord ref."
|
||||
[content fps zoom on-click ev]
|
||||
|
|
@ -667,64 +638,30 @@
|
|||
label]]))]
|
||||
[:div.ann-lanes {:style {:height lane-h :width width}
|
||||
:on-mouse-down #(scrub! @content fps zoom %)}
|
||||
;; VISUAL bars — one rectangle per piece (solid saved, dashed draft).
|
||||
;; 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.
|
||||
;; Saved and selected marks use the exact same projected pieces.
|
||||
(for [a visible
|
||||
[j [lo hi mid]] (map-indexed vector (:bars a))]
|
||||
^{:key (str (:id a) "-" j)}
|
||||
[:div.ann-bar {:title (:name a)
|
||||
:on-mouse-down
|
||||
(when-not (:draft a)
|
||||
(fn [e] (.stopPropagation e)
|
||||
(if linking
|
||||
(do (.preventDefault e) ; pick: link to this timeline
|
||||
(commit-link! {:kind :timeline :ref (:id a)} (:name a)))
|
||||
;; jump the playhead to this mark and scroll its
|
||||
;; card into view (don't drill into the timeline)
|
||||
(do (goto! lo true)
|
||||
(when-let [node (js/document.getElementById
|
||||
(str "ann-" (name (:id a))))]
|
||||
(.scrollIntoView node #js {:block "center" :behavior "smooth"}))))))
|
||||
(fn [e]
|
||||
(.stopPropagation e)
|
||||
(cond
|
||||
(:draft a) (do (goto! lo true)
|
||||
(rf/dispatch [::events/start-drawing (:id a) mid]))
|
||||
linking (do (.preventDefault e)
|
||||
(commit-link! {:kind :timeline :ref (:id a)} (:name a)))
|
||||
:else (do (goto! lo true)
|
||||
(when-let [node (js/document.getElementById
|
||||
(str "ann-" (name (:id a))))]
|
||||
(.scrollIntoView node #js {:block "center" :behavior "smooth"})))))
|
||||
:style {:top (+ 2 (* (lane-of (:id a)) 18)) :height 14
|
||||
:left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom))
|
||||
:background (str (:color a) (if (:draft a) "44" "cc"))
|
||||
:cursor (if (:draft a) "default" "pointer")
|
||||
:pointer-events (when (:draft a) "none")
|
||||
:border-radius 2
|
||||
:cursor "pointer" :border-radius 2
|
||||
:box-shadow (when (and (:draft a) (= mid active-mark))
|
||||
"0 0 0 2px var(--ink)")
|
||||
:border (str (if (:draft a) "1px dashed " "1px solid ") (:color a))}}])
|
||||
;; per-MARK interaction layer (draft/edit only): each mark is ONE unit
|
||||
;; spanning its whole extent — body-drag = move, click = draw, and
|
||||
;; exactly two end-handles = resize (roll-proxy). No matter how many
|
||||
;; visual pieces the mark has, it gets one handle pair, at its ends.
|
||||
(for [a visible :when (:draft a)
|
||||
mid (distinct (map #(nth % 2) (:bars a)))
|
||||
;; Exact context-local extent. The drag keeps the fixed endpoint
|
||||
;; at this value so its mark re-derives identically.
|
||||
:let [ext (scene/mark-extent scene (:id a) mid segs)]
|
||||
:when ext
|
||||
:let [[lo hi] ext
|
||||
active? (= mid active-mark)]]
|
||||
^{:key (str "edit-" (:id a) "-" mid)}
|
||||
[:div.mark-edit {:on-mouse-down (fn [e] (mark-drag! (:id a) mid :move lo hi fps zoom e))
|
||||
: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
|
||||
(for [a visible :let [[lo _] (first (:bars a))] :when lo]
|
||||
^{:key (str "lbl-" (:id a))}
|
||||
|
|
@ -1145,7 +1082,7 @@
|
|||
(or (:name g) (get-in g [:media :name]) (some-> gid name)))))
|
||||
|
||||
;; The `in:` row — every mark-group this annotation is FILED UNDER (:in). Click a
|
||||
;; chip to go there; ✕ un-files it (never its primary home). + opens a picker to
|
||||
;; chip to go there; ✕ removes that visibility edge. + opens a picker to
|
||||
;; file into any other group (annotation or timeline/act), at any nesting depth —
|
||||
;; that's the "link across arbitrarily nested groups" gesture (search, not drag).
|
||||
(defn- membership-chips [scene a authed?]
|
||||
|
|
@ -1269,7 +1206,7 @@
|
|||
(.stopPropagation e) (.preventDefault e) (reset! over? true)))
|
||||
:on-drag-leave (fn [_] (reset! over? false))
|
||||
;; plain drop MOVES the edge you grabbed onto this card; ⌜⌥/Alt⌟-drop ADDS
|
||||
;; (links, keeping the old home) — file-manager convention.
|
||||
;; (links, keeping the source edge).
|
||||
:on-drop (fn [e]
|
||||
(let [src (.. e -dataTransfer (getData "text/ann"))]
|
||||
(when (seq src)
|
||||
|
|
@ -1314,7 +1251,6 @@
|
|||
[:div.ann-title
|
||||
[:span.ann-swatch {:style {:background (:color a)}}]
|
||||
(when (:broken a) [:span.ann-warn {:title (:reason a)} "△ "])
|
||||
(when (:oor a) [:span.ann-warn {:title "Out of range — trimmed by the parent timeline"} "⚠ "])
|
||||
(:name a)]
|
||||
[:div.ann-actions
|
||||
[jump-control open a scene segs]
|
||||
|
|
@ -1357,8 +1293,7 @@
|
|||
nmap (into {} (map (juxt :id identity)) @(rf/subscribe [::subs/notes]))
|
||||
revealed @(rf/subscribe [::subs/revealed])]
|
||||
[:div.commentary
|
||||
;; this context's own description (links resolve in its parent), with an
|
||||
;; edit button — Edit drops into the parent timeline so marks are editable.
|
||||
;; This context's description; frame links resolve in this timeline.
|
||||
(let [cg (get-in scene [:groups ctx])]
|
||||
[:div.ctx-content
|
||||
(if (seq (:content cg))
|
||||
|
|
@ -1367,9 +1302,7 @@
|
|||
(when authed?
|
||||
[:button.edit-btn {:on-click #(rf/dispatch [::events/edit-here ctx])} "✎ Edit"])])
|
||||
(if (seq anns)
|
||||
;; group by REFERENCE parent(s): an annotation is listed under every
|
||||
;; timeline its marks were authored in (:parents), so a transcluded one
|
||||
;; shows under each context it belongs to, not just its structural parent.
|
||||
;; Cards follow visibility edges, independently of their footage.
|
||||
(let [by-parent (subs/annotations-by-parent anns)]
|
||||
(doall
|
||||
(for [a (get by-parent ctx)]
|
||||
|
|
@ -1390,18 +1323,17 @@
|
|||
(defn- pt-len [scene segs seg]
|
||||
(or (scene/seg-length segs seg) (scene/ref-length scene seg)))
|
||||
|
||||
(defn- frame-chip [scene segs gid mark-id which {:keys [seg f]}]
|
||||
(let [len (scene/ref-length scene seg)]
|
||||
[:div.pt-chip
|
||||
[:span.pt-chip-name (pt-name scene segs seg)]
|
||||
[:input.pt-frame {:type "number" :min 0 :max len :value f
|
||||
:on-change #(rf/dispatch [::events/set-mark-frame gid mark-id which
|
||||
(to-frame (.. % -target -value) len)])}]
|
||||
[:span.pt-dur (str "/" len)]
|
||||
[:button.pt-chip-x {:type "button" :title "Re-pick this end"
|
||||
:on-click (fn [e]
|
||||
(.stopPropagation e)
|
||||
(rf/dispatch [::events/unset-endpoint gid mark-id which]))} "✕"]]))
|
||||
(defn- part-editor [scene gid mid index {:keys [clip start end]}]
|
||||
[:div.mark-row
|
||||
[:span.pt-chip-name (or (scene/clip-name scene clip) (scene/ref-track-name scene clip))]
|
||||
(for [[which value lo hi] [[:start start 0 end]
|
||||
[:end end start (scene/ref-length scene clip)]]]
|
||||
^{:key which}
|
||||
[:label.pt-chip
|
||||
(name which)
|
||||
[:input.pt-frame {:type "number" :min lo :max hi :value value
|
||||
:on-change #(rf/dispatch [::events/set-part-frame gid mid index which
|
||||
(to-frame (.. % -target -value) hi)])}]])])
|
||||
|
||||
(defn- pending-frame-chip [scene segs {:keys [seg f]}]
|
||||
(let [len (pt-len scene segs seg)]
|
||||
|
|
@ -1530,10 +1462,8 @@
|
|||
nmap (into {} (map (juxt :id identity)) notes)
|
||||
live @(rf/subscribe [::subs/active-note-set])
|
||||
active @(rf/subscribe [::subs/active-mark])
|
||||
;; The root timeline's mark is absolute numeric [start/end], not a ref
|
||||
;; mark. Root editing is description-only, so do not run it through the
|
||||
;; annotation mark-row machinery.
|
||||
rows (when-not root? (scene/marks->rows scene (:marks d)))
|
||||
;; Root editing is description-only.
|
||||
rows (when-not root? (scene/marks->rows scene ctx (:marks d)))
|
||||
broken (if root? #{} (set (scene/broken-marks scene gid))) ; marks whose refs no longer resolve
|
||||
valid? (or root? (and (not (str/blank? (:name d))) (seq (:marks d))))
|
||||
save #(when valid?
|
||||
|
|
@ -1598,23 +1528,13 @@
|
|||
(reset! mark-drag {:src i}))} "⠿"]
|
||||
(when (contains? broken mark-id)
|
||||
[:span.ann-warn {:title "This mark's clip/reference no longer resolves"} "△ "])
|
||||
(let [pick (when (= (:mark-id pt) mark-id) (:which pt))]
|
||||
[:<>
|
||||
(if (= pick :start)
|
||||
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
|
||||
:placeholder "click a clip for start…"
|
||||
:on-cancel #(rf/dispatch [::events/draft-focus :new])
|
||||
:on-pick #(when-let [p (local->draft-point segs (:local %))]
|
||||
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]
|
||||
[frame-chip scene segs gid mark-id :start (:s row)])
|
||||
[:span.mark-arrow "→"]
|
||||
(if (= pick :end)
|
||||
[point-picker {:scene scene :ctx ctx :class "active" :auto-focus? true
|
||||
:placeholder "click a clip for end…"
|
||||
:on-cancel #(rf/dispatch [::events/draft-focus :new])
|
||||
:on-pick #(when-let [p (local->draft-end-point segs (:local %))]
|
||||
(rf/dispatch [::events/draft-click-seg (:seg p) (:f p)]))}]
|
||||
[frame-chip scene segs gid mark-id :end (:e row)])])
|
||||
[:details
|
||||
[:summary
|
||||
(if (seq row)
|
||||
(str/join " · " (map (fn [[lo hi]] (str lo "–" hi "f")) row))
|
||||
"Outside this timeline")]
|
||||
(for [[index part] (map-indexed vector (:parts mark))]
|
||||
^{:key index} [part-editor scene gid mark-id index part])]
|
||||
[: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")
|
||||
|
|
@ -1633,7 +1553,7 @@
|
|||
(for [ng (:notes mark) :let [n (nmap ng)] :when n]
|
||||
^{:key (name ng)}
|
||||
[mark-note-chip ng n live #(rf/dispatch [::events/unbind-note-mark gid mark-id %])]))))]))
|
||||
(when (or (= pt :new) (and (map? pt) (not (:mark-id pt))))
|
||||
(when (or (= pt :new) (map? pt))
|
||||
[pending-mark-block {:scene scene :ctx ctx :segs segs :pt pt :choosing? choosing?}])
|
||||
[:div.form-hint "Drag across the timeline to select a range (click to move the playhead)."]])
|
||||
(when-not choosing?
|
||||
|
|
|
|||
|
|
@ -100,7 +100,8 @@
|
|||
mid (:id (first (:marks original)))
|
||||
groups (set (keys (:groups (scene*))))]
|
||||
(rf/dispatch [::ev/edit-annotation :x])
|
||||
(rf/dispatch [::ev/resize-mark :x mid 30 80])
|
||||
(rf/dispatch [::ev/set-part-frame :x mid 0 :end 80])
|
||||
(rf/dispatch [::ev/set-part-frame :x mid 0 :start 30])
|
||||
(is (= [{:clip :a :start 30 :end 80}] (:parts (first (:marks (group :x))))))
|
||||
(rf/dispatch [::ev/restore-group :x original])
|
||||
(rf/dispatch [::ev/finish-edit])
|
||||
|
|
@ -112,10 +113,9 @@
|
|||
(setup! (assoc-in (seed) [:groups :x :marks 0 :drawings] [:drawing]) [:root :a1])
|
||||
(let [mid (:id (first (:marks (group :x))))]
|
||||
(rf/dispatch [::ev/edit-annotation :x])
|
||||
(rf/dispatch [::ev/set-mark-frame :x mid :start 20])
|
||||
(rf/dispatch [::ev/set-part-frame :x mid 0 :start 20])
|
||||
(is (= 20 (get-in (group :x) [:marks 0 :parts 0 :start])))
|
||||
(rf/dispatch [::ev/unset-endpoint :x mid :end])
|
||||
(rf/dispatch [::ev/draft-click-seg [:a1 0] 90])
|
||||
(rf/dispatch [::ev/set-part-frame :x mid 0 :end 90])
|
||||
(is (= mid (get-in (group :x) [:marks 0 :id])))
|
||||
(is (= [:drawing] (get-in (group :x) [:marks 0 :drawings])))
|
||||
(is (= [{:clip :a :start 20 :end 90}] (get-in (group :x) [:marks 0 :parts]))))))
|
||||
|
|
@ -126,7 +126,8 @@
|
|||
(let [before (s/resolve (scene*) :x)]
|
||||
(rf/dispatch [::ev/delete-annotation :a1])
|
||||
(is (= before (s/resolve (scene*) :x)))
|
||||
(is (= [:root :x] (s/path-to (scene*) :x))))))
|
||||
(is (= [:root :x] (s/path-to (scene*) :x)))
|
||||
(is (contains? (pane-ids) :x)))))
|
||||
|
||||
(deftest peer-delta-decodes-current-format
|
||||
(rf-test/run-test-sync
|
||||
|
|
@ -155,3 +156,75 @@
|
|||
(is (contains? (pane-ids) :x))
|
||||
(is (= [:d] (mapv :id @(rf/subscribe [::subs/active-drawings]))))
|
||||
(is (= #{:n} @(rf/subscribe [::subs/active-note-set])))))
|
||||
|
||||
(deftest nested-repeat-projection-is-shared-by-editor-and-lane
|
||||
(rf-test/run-test-sync
|
||||
(setup! fixture/base [:root])
|
||||
(rf/dispatch [::ev/open-draft])
|
||||
(rf/dispatch [::ev/draft-select-range 10 20])
|
||||
(let [outer (draft-gid)
|
||||
original (first (:marks (group outer)))
|
||||
repeated (assoc (group outer) :name "repeat" :marks
|
||||
[original (assoc original :id :second)])]
|
||||
(rf/dispatch [::ev/save-group outer (dissoc repeated :draft) nil])
|
||||
(rf/dispatch [::ev/finish-edit])
|
||||
(rf/dispatch [::ev/expand outer])
|
||||
(is (= [[1010 1020] [1010 1020]] (mapv :src (s/resolve (scene*) outer))))
|
||||
(rf/dispatch [::ev/open-draft])
|
||||
(rf/dispatch [::ev/draft-select-range 2 18])
|
||||
(let [inner (draft-gid)
|
||||
mark (first (:marks (group inner)))
|
||||
sc (scene*)]
|
||||
(is (= [(fixture/part :a 12 20) (fixture/part :a 10 18)] (:parts mark)))
|
||||
(is (= [[0 20]] (first (s/marks->rows sc outer [mark])))
|
||||
"Both raw slices project onto both repetitions; touching pieces coalesce")
|
||||
(is (= (first (s/marks->rows sc outer [mark]))
|
||||
(mapv #(subvec % 0 2) (s/lane-bars sc inner (s/content-segments sc outer)))))
|
||||
(rf/dispatch [::ev/file-into outer :root])
|
||||
(rf/dispatch [::ev/save-group inner (dissoc (group inner) :draft) nil])
|
||||
(rf/dispatch [::ev/finish-edit])
|
||||
(rf/dispatch [::ev/expand inner])
|
||||
(is (= [[1012 1020] [1010 1018]] (mapv :src (s/resolve (scene*) inner))))
|
||||
(rf/dispatch [::ev/open-draft])
|
||||
(rf/dispatch [::ev/draft-select-range 6 10])
|
||||
(is (= [(fixture/part :a 18 20) (fixture/part :a 10 12)]
|
||||
(:parts (first (:marks (group (draft-gid)))))))))))
|
||||
|
||||
(deftest editing-disjoint-reordered-repeated-parts-never-fills-gaps
|
||||
(rf-test/run-test-sync
|
||||
(let [parts [(fixture/part :c 10 20) (fixture/part :a 10 20) (fixture/part :c 10 20)]
|
||||
mark (assoc (apply fixture/mark :m parts) :drawings [:d] :notes [:n])]
|
||||
(setup! (fixture/annotation fixture/base :x [:root] mark) [:root])
|
||||
(rf/dispatch [::ev/edit-annotation :x])
|
||||
(is (= [[[10 20] [210 220]]]
|
||||
(s/marks->rows (scene*) :root (:marks (group :x)))))
|
||||
(rf/dispatch [::ev/set-part-frame :x :m 1 :start 12])
|
||||
(let [edited (first (:marks (group :x)))]
|
||||
(is (= (assoc-in parts [1 :start] 12) (:parts edited)))
|
||||
(is (= [:c :a :c] (mapv :clip (:parts edited))))
|
||||
(is (= [:d] (:drawings edited)))
|
||||
(is (= [:n] (:notes edited)))
|
||||
(is (= [[3010 3020] [1012 1020] [3010 3020]]
|
||||
(mapv :src (s/resolve (scene*) :x))))
|
||||
(is (= [[[12 20] [210 220]]] (s/marks->rows (scene*) :root [edited])))))))
|
||||
|
||||
(deftest endpoint-crossing-clamps-to-an-instant
|
||||
(rf-test/run-test-sync
|
||||
(setup! (seed) [:root :a1])
|
||||
(let [mid (:id (first (:marks (group :x))))]
|
||||
(rf/dispatch [::ev/set-part-frame :x mid 0 :end 5])
|
||||
(is (= [(fixture/part :a 10 10)] (get-in (group :x) [:marks 0 :parts])))
|
||||
(is (= [[1010 1010]] (mapv :src (s/resolve (scene*) :x))))
|
||||
(rf/dispatch [::ev/set-part-frame :x mid 0 :end 1000])
|
||||
(rf/dispatch [::ev/set-part-frame :x mid 0 :start -10])
|
||||
(is (= [(fixture/part :a 0 100)] (get-in (group :x) [:marks 0 :parts]))))))
|
||||
|
||||
(deftest last-edge-removal-remains-discoverable-at-root
|
||||
(rf-test/run-test-sync
|
||||
(setup! (seed) [:root])
|
||||
(rf/dispatch [::ev/unfile :x :a1])
|
||||
(is (contains? (pane-ids) :x))
|
||||
(rf/dispatch [::ev/ann-drag-start :x :root])
|
||||
(rf/dispatch [::ev/reparent :x :b1])
|
||||
(is (= #{:b1} (s/membership (scene*) :x)))
|
||||
(is (not (contains? (pane-ids) :x)))))
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
(deftest one-mark-one-row-no-extra-entities
|
||||
(let [m (s/make-mark base :root 40 210)]
|
||||
(is (= [(part :a 40 100) (part :b 0 100) (part :c 0 10)] (:parts m)))
|
||||
(is (= [{:s {:seg :a :f 40} :e {:seg :c :f 10}}] (s/marks->rows base [m])))))
|
||||
(is (= [[[40 210]]] (s/marks->rows base :root [m])))))
|
||||
|
||||
(deftest projection-preserves-root-clip-identity
|
||||
(let [sc (-> base
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
(let [sc (annotation base :x [:root] (mark :m (part :a 0 100)))
|
||||
moved (assoc-in sc [:groups :x :in] [:other :root])]
|
||||
(is (= (s/resolve sc :x) (s/resolve moved :x)))
|
||||
(is (= #{:root :other} (s/membership moved :x)))
|
||||
(is (= #{:root} (s/membership moved :x)))
|
||||
(is (= (s/resolve moved :x)
|
||||
(s/resolve (assoc-in moved [:groups :x :in] [:root :other]) :x)))))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue