CreatorTrack Clips Review Suite (Frame.io treatment)¶
URL: https://mkdocs.justinsforge.com/memory/handoffs/creatortrack-clips-review-suite-2026-07-06/
Frame.io-style review tooling on the Clips app, built and verified end-to-end on the
feat/clips-review-suite dev slot. Research basis: Frame.io V4 commenting (timecoded +
range comments, annotations, threads), version stacks, review links, Premiere comment
export.
What shipped¶
Data layer (migration 20260706T235856_...clips_review_suite...)
- app.clips + review_status (none | needs_review | in_progress | approved | changes_requested), version_group, version_num.
- app.clip_comments: threaded (parent_id), timecoded (t_start, t_end for ranges), annotation jsonb (normalized 0..1 strokes), resolve state, guest authors (author_id NULL + author_name).
- app.clip_review_shares: tokenized review links (rvw_ + 18 random bytes), label, allow_comments, revoked_at/expires_at.
- RLS on new tables mirrors app.notifications (core.is_owner() OR membership). Also added the is_owner bypass to app.clips/app.clip_keyframes policies: the Clips demo mode and the share-link service reads rely on owner elevation, which the old membership-only policy silently blanked.
Server (lib/clips-review.ts, lib/clips.ts extensions)
- Comment CRUD, resolve, status, stack/unstack (capture-time ascending = v1..vN), share create/list/revoke; all RLS-scoped with demo-elevated wrappers matching listClipsDemo.
- Share-token path: reviewByToken() validates revoked/expired on every request and runs as a service identity; the clip id always resolves from the token, never the client.
- ClipListItem gains reviewStatus/versionGroup/versionNum/openCommentCount (grouped unresolved-count query in listClips).
API
- /api/clips/[id]/comments (GET/POST), /api/clips/comments/[id] (PATCH resolve/edit, DELETE), /api/clips/[id] PATCH now takes reviewStatus, /api/clips/versions (POST stack / DELETE unstack), /api/clips/[id]/shares (GET/POST) + /api/clips/shares/[id] DELETE (revoke).
- Public, session-free: /api/review/[token] (GET context, POST guest comment), plus /footage (Range original), /play (720p proxy, cached or on-the-fly ffmpeg), /poster.
UI
- VideoPlayer (shared kit): optional apiRef imperative surface, onTimeChange, clickable comment markers strip (dots + range bars) above the scrubber, overlay slot, J/L 10s jog keys (K/space, ,/. frame-step already existed).
- app/(suite)/clips/ClipReview.tsx: full-screen review surface. Timecoded/range/threaded comments (click = frame-exact seek + pause), draw-on-frame annotation canvas (5 colors, undo/clear, re-projects on any render size), resolve/reopen with a Resolved toggle, status select, version switcher + unstack, share-links modal (create/copy/revoke), CSV comment export, tag editor carried over.
- Gallery: status filter, version stacks collapse to the latest cut (vN in meta), open-comment counts, "Stack versions" action on multi-select.
- app/review/[token]/page.tsx: guest review page (no login, no suite chrome); name remembered locally, comments post as "Name (guest)"; dead links render a friendly revoked/expired screen. Security: token is app-level gating only; ship behind Cloudflare Access per the never-expose rule.
Verified end-to-end (dev :3068, real test videos)¶
Two ffmpeg-generated 45 s H.264 clips ingested via scripts/ingest_clips.py into ws 12
(/mnt/workspace/creatortrack-uploads/review-test/). Exercised in Playwright + curl:
comment/range/reply/annotation create, click-to-seek frame accuracy (paused at 00:00:03.500),
annotation re-projection, resolve/reopen, status change, stack + switcher + gallery collapse,
share link create -> guest page -> guest comment ("Client Sarah"), revoked/invalid token 404,
Range streaming 206, CSV export download, J/L/frame-step keys. tsc --noEmit and eslint clean
(one pre-existing VideoPlayer set-state-in-effect error exists on main; untouched).
Gotchas hit¶
- Dev-slot reaper tore the slot down mid-build (no browser connections = idle). Re-bootstrapped, then parked a keepalive holding one TCP connection to :3068:
python3 <scratchpad>/ct3068_keepalive.py(running in session background). Kill it once the branch is merged or the slot is intentionally torn down; without it the reaper reclaims the slot after 45 min idle. sql\ANY(${ids})`in drizzle renders a tuple, not an array; useinArray()`.- Slot DB clips policy had no owner bypass, so demo-elevated reads returned empty; fixed in the migration (applies to prod at merge; matches the notifications precedent).
Review¶
- Branch:
feat/clips-review-suite(5 commits on top of d11144f; NOT merged, NOT deployed) - What to review: open Clips, click the
test_review_v2.mp4tile (version switcher has v1 with seeded comments + annotations); try Draw, a range comment (type, scrub forward, "Set range out-point"), Share -> open the link in a private window, Export. - Preview URL: http://100.97.43.104:3068