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>
Replace the manual lane-h/row-h/clientHeight math (fragile, clamped oddly for
top rows) with scrollIntoView on the target track's gutter label. The label
lives in the fixed track-name column, so the browser scrolls the vertical axis
only — clean, no geometry, and structurally can't fight the horizontal follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add :meta {:scroll-to bool} to annotations, toggled by a "Scroll to clips on
jump" checkbox in the annotation form. goto! now separates follow? (horizontal
recenter) from scroll-to? (vertical scroll to the clip's track); the vertical
scroll only fires for annotations that opt in, resolved from the canonical
scene group at each jump site (jump button/popover, script highlight, link
chips). Persists for free via editable-group + the backend's opaque group
merge; round-trips through json keywordize, so no migration needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
follow! gains an optional smooth flag. The jump/link path (goto! with follow?)
now glides both axes; playback-follow stays instant (smooth would fight the
per-frame updates) and scrubbing doesn't scroll at all, so clicking stays snappy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
goto! with follow? already recentred horizontally on the playhead. Now it also
scrolls the tracks container vertically so the clip under the landing frame is
brought into view — for link chips, annotation jump buttons, and script
highlights. No-op when the tracks already fit. Accounts for the pinned
annotation-lane header height in the scroll math.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the mid-page .project-bar with a proper top nav bar pinned above the
video/timeline, styled like a classic Mac window title bar (centered title
flanked by racing stripes).
- left: TL wordmark + thumbnail-status indicator
- center: ‹ back-to-projects + project name, between the title stripes
- right: Share popover and a user dropdown with Log out
- Share offers two clearly named links, copied to clipboard:
"Copy link to this moment" (current context + playhead) and
"Copy link to the whole project" (root)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thumbnails failed immediately on any clip past ~2 min. The one-pass select
expression joined frames with a flat a+b+c+... chain, which overflows ffmpeg's
recursive expression parser past ~120 terms ("Error while parsing expression").
Join the same terms as a balanced tree (depth O(log N)) instead — identical
selection, still one decode pass, exact per-tile frames preserved.
- generate_thumbnails management command: runs synchronously and streams each
phase to stdout, so jobs are watchable without tailing app logs
- surface ffmpeg's stderr instead of swallowing it in CalledProcessError
- fly: min_machines_running = 1. Background thumbnail jobs outlive the HTTP
request that triggers them, so scale-to-zero let the proxy autostop the VM
mid-job and leave projects stuck "running"
- timeline: replace the two split playhead bars (pinned lanes vs scrolling
tracks) with one full-height overlay, positioned in JS to track horizontal
scroll and sit above the translucent header
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ffmpeg's select filter re-decodes the whole clip on every pass, so chunking by 30
turned a 670-frame job into 23 full decodes (minutes on shared CPU — long enough
to be killed mid-run and leave thumbnail_status stuck at 'running'). One pass now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend: POST /api/projects/<id>/script/ (owner/collaborator) saves the script
FileField and returns the refreshed detail. Frontend: when a project has no
script, the script pane shows a PDF file picker that uploads on selection; the
returned detail carries the new URL so the pane renders it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each stored annotation carries :v (schema-version, currently 1). tl.scene gains
a transformer registry and migrate fn that chains version upgrades on load;
save-group and open-draft stamp the current version. Empty transformer set at
v1 — bump schema-version and add a transformer when the model changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swap the create-project upload from :http-xhrio to a raw XHR effect with
upload.onprogress, surface it as a 1-bit progress bar; disable Create while
uploading.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Zoom X/Y sliders labelled ↔ / ↕
- dark toggle ◐/◑, pause ❚❚, warnings △, drop 🔗 — no color emoji in the
1-bit theme
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploy:
- Multi-stage Dockerfile (shadow-cljs release → Python+ffmpeg runtime), fly.toml
(one machine, scale-to-zero, SQLite on a volume), .dockerignore, DEPLOY.md
- Same-origin release build: api-port defaults "9001" for dev, "" in release
(no CORS in prod)
- env-driven settings (SECRET_KEY/DEBUG/ALLOWED_HOSTS/DB_PATH/CSRF), WhiteNoise
serving static + the SPA, R2 (S3-compatible) media storage when R2_* is set
- thumbnails reworked storage-agnostic: download clip, ffmpeg select-pass, upload
tiles via default_storage (R2 in prod, disk in dev)
- pin channels/daphne; add whitenoise/django-storages/boto3
UI:
- frame step buttons flanking play (disabled at timeline bounds)
- Add-annotation button moved into the toolbar, made prominent
- picker mode pulses a 1-bit glow on clips / frame readout / (while linking)
annotation bars; linking an annotation bar inserts a timeline link
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Retheme the whole app 1-bit HyperCard: white cards, hard 1px black borders,
Chicago/Geneva/Monaco fonts (with web fallbacks; real fonts go in /fonts, see
fonts/README.md), inverted-on-press buttons, a 50% desktop dither, black Mac
title bar. Palette runs on CSS variables.
- Dark mode toggle (toolbar + home header): flips the palette vars via a class
on <html>, persisted in localStorage and applied before first paint.
- Per-annotation fat colored left stripe in the annotation pane; annotation name
bumped above the md h1; full-color (no longer veiled/desaturated) thumbnails.
- Jump popover now labels each part with the clip name + crop-adjusted mark-time
frame (e.g. "CU Tashi (1) 45f"), matching the annotation editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend / dev:
- Bind dev server to 0.0.0.0 and allow Tailscale MagicDNS + tailnet-IP origins
in ALLOWED_HOSTS / CORS / CSRF so the app is reachable over Tailscale
- Unified, idempotent dev launcher (Django + media + shadow watch), hot reload,
no manual compiles
Frontend:
- Migrate fetches to re-frame :http-xhrio with failure handling; surface
network / load / save errors where the user expects them
- Extract markdown + link formatting into tl.md: parse/serialize round-trip
(recursive chip serialization fix), block markdown (headings/lists/paragraphs)
- Timeline-opening links: navigate the stack to any reachable timeline;
autocomplete lists all timelines (root included, orphans dropped) with a live
preview that pushes the stack and reverts on commit/cancel
- Annotation pane shows the current context's description with edit-in-context
(drops into the parent timeline for marks, pops back when done) + root content
- Fixes: deep-link playhead seek, replay-from-end, mobile input zoom, notch
safe-area insets, autofocus the content field, freeze form to snapshot context
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Script pane: PDF tab beside annotations; "Add script annotation" arms
highlight mode on the draft, ✓/✕ to confirm/cancel, auto-scrolls to the
active annotation's highlights during playback. Adds Project.script.
- Realtime: Django Channels websocket per project broadcasts attributed
scene deltas after each save; peers merge them in (keeping local drafts).
- Logout link in the editor project-bar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- the <video>'s own play/pause now engage/disengage the segment-aware tick
(:on-play/:on-pause), so the native controls drive the playhead and follow the
multicam cuts, not just the toolbar button (which now just toggles the video)
- entering/leaving a context (expand/collapse/pop-to) pauses and seeks the video
to that context's remembered playhead ([:view :playheads ctx], 0 = its start),
so the playhead tracks the timeline stack instead of ticking a stale context
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The root timeline placed each clip at its media-in (source) frame, which is
fractional (media-offset 50641.358…), so adjacent clips of a contiguous multicam
edit showed ~1-frame gaps; clicking a gap hit no segment and local->source
returned the last frame ("glitch to the end"). The OTIO timeline is gapless —
clips tile by their accumulated timeline :start. Carry :start through from-otio
and lay content-segments at [start, start+len] (src stays the source range), so
the assembled program is contiguous and maps cleanly to scattered source frames.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A continuous selection across N clips is N marks → N bars, and OTIO's fractional
media offsets leave ~1-frame gaps between adjacent clips, so the jump control
showed "jump (5)" for a continuous stretch. merge-bars snaps bar endpoints to
whole frames and joins touching ranges; only a real >=1-frame gap splits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
open-draft used (gensym "ann-"), whose counter resets on every page load, so the
first annotation added after a refresh reused a prior gid (ann-2, …) and the
per-id merge overwrote the existing annotation. Use a uuid-based gid instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Keyword *values* don't survive JSON: clj->js stringifies :annotation/:root/clip
refs and js->clj only re-keywordizes keys, so a reloaded annotation had string
:type/:parent/:ref and was filtered out of the scene. Re-keywordize those fields
on load (scene/restore-annotations) and give marks string ids (random-uuid was
serializing as a UUID object).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- frontend: hash routing (project list / create / editor), per-project init
(load OTIO + scene + clip from the API), create form with otio + clip upload,
annotation saves/deletes pushed as deltas, edit/add buttons gated on auth
- auth: own login form in cljs posting to session login/logout endpoints
(httpOnly cookie — no JWT in JS); /api/me/ drives the UI's signed-in state
- backend: add /api/login/ and /api/logout/ (session auth)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Project gains a clip FileField; POST /api/projects/ creates one from an
uploaded otio + clip (<=100 MB), owned by the requester
- add /api/me/ and project-detail; scene GET is public (view without login),
PUT stays owner/collaborator-only
- serve media in DEBUG (range requests for video seek); CORS for the cljs dev
origin with credentials
- seed_demo now builds the Challengers project from the bundled otio + 480p clip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PUT now takes {changed, deleted} and merges per annotation id, so concurrent
edits to different annotations both survive; same-annotation edits are
last-write-wins by arrival. No version/locking. No-op saves don't write a
revision. Tests cover merge, LWW, stale-client safety, attribution
(server-stamped/spoof-resistant, creator preserved), revisions, and access.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Server-authoritative attribution: on each scene PUT the server diffs incoming
annotation groups and stamps createdBy/editedBy (+ timestamps) from request.user,
ignoring client-sent stamps so authorship can't be forged. Each save also writes
a Revision (user, time, +N ~N −N summary, snapshot of the annotation layer),
visible in the admin and via /revisions/. Projects gain collaborators so several
users can edit one project and get distinct attribution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Project (owner, OTIO file, fps, scene JSON) persists the timeline. Session
API: list projects, GET/PUT scene, serve OTIO. Admin manages users/projects;
seed_demo creates a project from the bundled one_two_three.otio.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>