Fixes the resize-drift and boundary bugs found while exercising the marks-first
flow, plus Chunk 7 (orphan polish). Frame model made consistent rather than
patched.
- Boundaries are half-open [start,end) everywhere; drop the merge-bars ≤1-frame
fudge and the independent from-otio rounding that created the gaps it papered
over. Clips stay EXACT (they tile at shared boundaries); frame-accuracy is
applied at the mark the user creates (selection->marks rounds :at) and at
display, never by rounding clips or bars.
- Unify the ref :at <-> source conversion: at->local / at->src / src->at, all via
the target's full resolution, correct for scattered multi-clip (nested) proxy
targets. Delete target-range (the lossy single-segment [xs xe] shortcut that
made selection->marks / point-frame / at->frame / seg-point wrong when nested).
This is what fixed the resize dragging one handle moving the other.
- Lane interaction is per-MARK, not per-visual-bar: one draggable unit + two
end-handles at the mark's exact extent (scene/mark-extent), driven so the
dragged endpoint rounds and the fixed one stays exact. Timeline drag-select;
click = scrub. Visible edge grips.
- Share display-point between the editor rows and the jump popover (context-local
whole frame). Drawing autocommits; toolbar moved off the video.
- Chunk 7: broken annotations greyed + sorted down + per-mark warnings.
Suite green except the pre-existing annotations-survive-json-roundtrip; app
compiles clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chunk 5 (draft/edit lane) + follow-up polish on the marks-first flow.
- Lane bars are [lo hi mark-id]; draft/edit bars are interactive via mark-drag!:
click = draw on that mark, body-drag = move, edge handles = resize. All route
through ::reroll-proxy -> roll-proxy (in-place within a clip, add/drop across a
boundary). Active mark shows a ring in the lane and an accent row in the pane.
- Timeline drag-select: drag across clips/empty space to select a region (proxy +
auto-draw); a plain click moves the playhead (scrubbing works in marking mode).
- Frame accuracy: snap OTIO's fractional RationalTime to whole frames at the seed
boundary (from-otio) so nothing sub-frame enters the model; round display too.
- Drawing autocommits (no save/cancel buttons — the form's Save/Cancel is the
rollback net); toolbar moved to a bottom strip off the video; draw mode tears
down on form finish.
Tests: from-otio snapping, per-mark lane bars carry mark-id. Suite green
(pre-existing json-roundtrip failure only); app compiles clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Track the full set of annotations the playhead is inside and diff against last
tick; scroll only for newly-entered ones, so a permanently-active annotation
(e.g. a full-timeline one) counts as new just once and stops hijacking the
scroll. Pick one deterministically among the newly active (greatest lo, then
tightest bar).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior "first active in anns" pick was masked by a full-timeline annotation
(bars [[0 N]]) that always won, so primary never changed and the scroll never
re-fired. Pick the active bar with the greatest lo (ties → tighter bar) instead,
so entering a nested/later annotation changes the pick and scrolls to it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the commentary-pane auto-scroll into playhead-fx (the single per-playhead
side-effect hook): pick ONE annotation deterministically (first active in the
sorted anns) so overlapping annotations don't race per-card, and scrollIntoView
its card when that pick changes. Runs on any playhead move (playback or scrub).
Drops the unreliable commentary-scroll/active-annotation component.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the separate commentary-scroll/active-annotation component and trigger the
scroll where the highlight already fires: when a card's ::annotation-active?
flips true (playhead entered), scrollIntoView it — the same call the lane click
uses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite commentary-scroll as a lifecycle component that scrolls in
component-did-update via scrollIntoView (offsetParent-agnostic, so it works for
nested cards too), replacing the fragile after-render + manual offsetTop math.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Playhead-in-bar tests (active annotation(s), drawing/note bindings) are now
half-open [lo hi) via a shared in-bars? helper. merge-bars ceils the exclusive
end up to the next clip's first frame, so the old inclusive <= double-counted
the boundary: two annotations lit at once and clip A's drawing bled onto clip
B's first frame.
- commentary-scroll uses scrollIntoView instead of manual offsetTop math, which
broke once cards can nest (a nested card's offsetParent is its parent .ann).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Render the breadcrumb bar even at root so the current context's crumb is always
visible — it already acts as a drop target while dragging, so you can drag an
annotation out onto the current timeline to un-nest it. Back button stays gated
to nested contexts. Also drop the child-card left spine (indentation is enough).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Greedy annotation lane collapse: non-overlapping annotations share a lane
(packed by earliest start, then longest span); each annotation keeps all its
segments in one lane.
- Clicking a lane bar jumps the playhead to that mark and scrolls its card into
view instead of drilling into the annotation timeline.
- "Show N annotations" toggle nests an annotation's children (recursively) in the
commentary pane and surfaces them in the lane; children reparent-drag correctly
(stopPropagation so the child, not the parent, is moved).
- Out-of-range warning (⚠) on cards and the content display when an annotation
references frames its parent timeline trims; the lane shows the best-effort
in-range portion (scene/nested-src + scene/clip-loss?).
- Annotation-card markdown: 12px body, larger heading ramp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tags live in annotation :meta :tags (plain strings). The edit form gains a Tags
section: cute price-tag chips + a shared string autocomplete that suggests any
tag used elsewhere in the project (allow-new). Tags render small at the card's
bottom-left.
The timeline controls gain a "▽ Tags" filter opening a dropdown of every project
tag (plus an "Untagged" entry mapping to a :untagged sentinel), each with an
eye to show/hide. Hidden tags drop matching annotations from both the commentary
list and the timeline lane via a new ::hidden-tags view state.
Both the adder and the filter share one `autocomplete` component (just different
callbacks).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The jump dropdown described each target with clip-at, which re-derived the clip
by looking up the (floored) bar-edge frame in the content segments — at a clip
boundary that lands one clip early, so a mark on clip B could read "clip A <last
frame>" even though the jump seeks correctly. The annotation editor labels each
mark straight from its :ref via clip-label, so the two disagreed.
Add scene/jump-targets (built on runs, keyed by the owning mark) and label the
popover with clip-label + the mark-time frame — the same basis as the editor.
Drops the now-unused clip-at.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Glowing drop targets for annotation reparenting (cards + breadcrumbs), the
per-endpoint re-pick ✕, and the drag-to-reorder grip + drop line — the visuals
for the interactions landed in 7f3d903.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the 1-bit black pulse on pickable clips/frame-readout/annotation bars
with a cyan↔magenta glow that swells and shifts hue, so what's clickable in
picker mode reads at a glance. Adds --pick-a/--pick-b for reuse.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both derefed the raw ::playhead in their render bodies, so the whole toolbar
(zoom/row sliders) and the share menu (even while closed) reconciled 60fps
during playback. toolbar now subscribes coarse ::at-start?/::at-end? edges
(flip only at the timeline ends) and reads the exact playhead at click time;
share-menu reads the playhead at click time too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
commentary dereferenced active-annotation/active-annotations/active-note-set
in its body, so the whole card list rebuilt (and React reconciled every card)
on every playhead bar-crossing during playback and on context enter — the
annotation-only jank + the enter "double flash".
Push the per-item active state into granular per-id subs
(::annotation-active? / ::note-active?): each card and each bound-note chip
subscribes to its own state, so a crossing re-renders only the item(s) that
flipped. The active-card scroll moves into a nil-rendering component that owns
the active-annotation sub, so commentary no longer re-renders for it either.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- backend: the scene PUT only broadcast to peers when annotation counts
changed, so drawing/script-note edits saved but never synced live (only
after a reload). Broadcast any gid that actually moved, regardless of type.
- perf: active-drawings/active-note-set recomputed fully on every playhead
tick (active-drawings even re-resolved each annotation per mark). Split the
bar computation into memoized ::drawing-bars/::note-bars (scene/ctx/segs
only) and reduce per-frame work to cheap interval tests.
- perf: only subscribe drawing-layer to the boil clock when something is
actually animating (no more 7fps idle re-renders).
- drawings: pen colour picker + stroke-size slider, still defaulting to the
annotation colour per session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A drawing is a first-class :type :drawing entity (normalized strokes +
seed), bound to a mark via mark :drawings [gid] — same referrer-pointer
model as notes. It renders as a boiling SVG overlay on the video whenever
the playhead is in the bound mark's span (the same hook that lights an
annotation active), including when that annotation is the pushed context.
- Author in an overlay on the video monitor: pencil, eraser (deletes a
whole stroke), undo; the 🖼 button on each mark row enters draw mode
and seeks to the mark's frame.
- Boil is procedural: 3 seeded jitter variants of the base paths cycled
at ~7fps; only the strokes persist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the old per-annotation :script rects with a first-class
:script-note entity (name, color, id'd regions), persisted/synced
through the same generic scene channel as every other group.
- Author regions by selecting text on the pdf.js text layer; a region
stores char offsets + per-line rects + per-range commentary.
- Bind notes to an annotation or a specific mark (drag-drop); a bound
note shows »»» while the playhead is within its span.
- Script pane: note rail with per-note eye toggles, fit-to-width + zoom
controls, plain-highlighter rendering (multiply blend, no borders).
- Click a highlight to open + focus its note; click a note chip on an
annotation card to jump to its first passage.
- Schema v2 migration drops the legacy :script rects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keep button container always rendered but use visibility:hidden to hide
buttons instead of conditionally rendering them. This preserves DOM
structure and prevents scroll position reset on hover.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Remove the unhide button. Instead show Edit (✎) and Expand (⤢) buttons
on hover so users can edit for any reason or expand to zoom in.
Make title text smaller (10px) and reduce card height to 24px.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Hidden annotations appear in the annotation pane as small, disabled-looking
cards with the dither pattern overlay (var(--desktop)) at 40% opacity.
On hover, an "Unhide" button appears to remove the hidden flag.
Full content, controls, and metadata are hidden.
Hidden annotations still work in marks/autocomplete and maintain their marks.
They appear as normal bars in the timeline.
Add 'Hide from timeline' checkbox in the annotation edit form.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Add 'hidden' meta flag to annotations to hide them from the timeline lane
while keeping them available for linking. Hidden annotations appear as
small 6px colored rectangles with faint color and dither pattern overlay.
Click on a hidden rectangle to unhide it. Hidden annotations' marks are
not resolved/used, and they don't trigger scroll-to-clip during playback.
Add 'Hide from timeline' checkbox in annotation create/edit form.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Increase gutter-lanes height by 24px to align with the time ruler above the
annotation lanes. Update timeline-body-scroll padding to match.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Dynamically calculate tick intervals to maintain ~80px minimum spacing between
marks regardless of zoom level. Prevents overlapping labels at high zoom.
Change time format from adaptive (m/s/·) to consistent HH:MM:SS or MM:SS format.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
When a project opens with a ?f=<frame> query parameter, the playhead position
is now visible in the timeline view. Previously, the playhead was positioned
correctly in the UI but the timeline didn't scroll to show it. This triggered
scroll-to-seg-track on first render when playhead > 0 and segments are loaded.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
When an annotation with scroll-to enabled spans multiple bars (discontinuities),
only scroll when the playhead enters a new bar, not for every mark. Track
[ann-id bar-idx] pairs instead of just the current track to avoid excessive
scrolling as the playhead moves within a bar.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Stale/fail flashes came from the editor rendering the previous project's
:project/:scene/:view (and a stale :load error) while the next loaded. Reset
those keys when opening a project or leaving to the list/create page, and render
the editor body only when :ready — otherwise a centered spinner ("Loading…") or
error message, with the top bar (and back button) still available.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The real iOS issue: it won't load the media until the first tap and ignores JS
currentTime until the element is seekable (populated very late), so every
load/seek hack started playback from 0. A #t=<seconds> media fragment on the
src is honoured by the media loader during the tap-triggered load itself, so it
starts at the right frame (and renders that frame instead of black). Bake the
deep-linked playhead into the src fragment, cached per clip so live playhead
changes don't reload the element; currentTime still drives in-session seeks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous fix still failed on iOS: at the first tap the element isn't
seekable (readyState 0), so the seek in engage-play! was dropped, and the
existing on-loadedmetadata seek is guarded by (when-not @play) — already true by
then — so it was skipped too, playing from 0. Now seek-video! stashes the target
when the element isn't seekable yet, and loadedmetadata/canplay flush it
(regardless of play state).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On iOS the element doesn't load (so a deep-linked pre-seek doesn't stick) until
the user's first tap, so engage-play! read currentTime=0 and played from the
top. Seek to the playhead inside the play gesture and drive from there; for
normal pause/resume the playhead already equals currentTime, so it's a no-op.
Drops now-unused seg-at-src.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ::active-annotations no longer falls back to the nearest annotation, so when
the playhead is outside every mark nothing is highlighted.
- clip label background is translucent (color-mix paper 70%) like the lane
bars, so the thumbnail reads through while text stays legible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- active annotation is now loud: thick colour spine, a wash of its own colour,
a hard offset shadow, bold title (was just a faint 2px shadow). Annotation
colour drives it via a --ann-color custom property; added a colour swatch by
each title so the commentary reads its colours.
- clip name is a solid bordered label chip pinned top-left instead of black
text + white halo, which muddied over busy thumbnails.
- highlight ALL annotations whose marks contain the playhead, not just one
(new ::active-annotations set sub), so overlapping marks light up together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A homescreen launch opens the manifest start_url ("/") with no route, so the
deep state was lost. Persist the in-app URL (already kept current with stack +
playhead) to localStorage, and on a standalone cold launch with a bare hash,
re-enter it before the router reads the URL. Browser tabs are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
::active-annotation falls back to the nearest annotation (it drives the
commentary highlight), so gating playback clip-follow on it fired for every
clip. Gate on strict bar containment of a :scroll-to annotation instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reframe the per-annotation :scroll-to flag. Jumps/links now ALWAYS scroll the
target clip into view (unconditional, no flag). The flag instead drives normal
playback: while the playhead plays through an annotation that opts in, each of
its clips is scrolled into view as the playhead crosses onto that clip's track
(deduped to fire only on track change; reset when playback stops). Form label
updated to "Follow clips during playback".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>