feat: add R2 video processing and project reel - #136
Conversation
Replace the Stream direct-upload lifecycle with durable R2 multipart sessions, immutable queued submissions, active-project database constraints, and confirmed retirement that retains stored objects. Add browser-side part streaming and persisted resume metadata, remove attachment promotion and Stream job endpoints, and cover authorization, concurrency, expiry, idempotency, and retention in local tests.
Start one deterministic, attempt-fenced Cloudflare Workflow when an R2 multipart upload completes, and route scoped source/output access through a Container outbound handler without exposing bucket credentials. Add a digest-pinned multi-architecture FFmpeg image that probes, rejects overlong or malformed input, performs two-pass -16 LUFS normalization, supplies deterministic silence, and emits immutable H.264/AAC fast-start MP4 derivatives. Configure local concurrency one and prepared production concurrency two, with real Docker and local Workflow smoke coverage.
Serve current canonical R2 derivatives through authenticated same-origin endpoints with exact single-range responses and storage-neutral playback descriptors. Keep curated ordering while adding project team overlay data without exposing unrelated user fields.\n\nReplace HLS attachment with direct MP4 playback, embed ready videos on project pages, and harden the double-buffered reel with one-clip preloading, active-slot advancement, recoverable errors, pause/skip, and fullscreen controls.
Replace fake readiness and direct SQL promotion with generated media uploaded through local multipart R2, processed by the real Workflow and pinned FFmpeg Container, then verified through probe, authenticated ranges, playlist inclusion, retirement, and retained bytes.\n\nAdd reproducible processor benchmarks, isolated production resource declarations capped at two, structured processing events, and an operations runbook covering approval, provisioning, smoke, observability, rollback, and retained storage. Remove superseded Stream measurement and archive surfaces, and include processor plus real-byte readiness in the complete verification gate.
Keep the legacy project_videos and stream_events schema intact during the rollout and move the new R2 lifecycle onto video_submissions. This lets both the deployed pre-release Worker and the R2 Worker operate after migration 0007, preserving a valid Worker rollback target without restoring active Stream behavior in the new release.\n\nAdd an executable populated-schema migration proof for legacy reads and writes, R2 writes and active constraints, retained data, and foreign keys. Update rollout automation and documentation to identify the safe expand/deploy/rollback sequence and require a separately approved future contraction.
Fence stale upload rows in an active expiring state before aborting their exact R2 multipart session, and release the project slot only after storage cleanup is confirmed. Treat missing uploads idempotently while preserving the fence and returning a retryable error for transient abort failures. Lease in-flight completion attempts so expiration cleanup cannot preempt a current completion. Cover lost resume metadata, duplicate fresh creates, missing multipart state, transient failures, and completion races.
When an initial canonical transcode cannot reach the loudness target because dynamic true-peak limiting is required, re-analyze the derivative and run one audio-only corrective pass while copying the encoded video. Revalidate codec, dimensions, fast-start layout, and the existing ±0.7 LU gate after correction. Add a deterministic high-crest fixture that reproduces the rejected short-clip behavior.
Let project managers restart a failed Workflow attempt against the retained immutable original instead of retiring and re-uploading the video. Refresh the project-video query cache from the retry response and cover the recovery action in the video UI test.
Clear upload progress once the multipart upload completes, and let the inline player represent a ready video without a duplicate status card or watch-page link. Remove the storage fine print and cover the streamlined completed state in the video UI tests.
Move the existing video-removal action into the panel header and label it clearly as delete video so managers can find it without scrolling past the player. Explain in the confirmation that deletion enables a replacement upload, while preserving the existing retirement-backed storage behavior.
Include every ready project video without requiring a manual screening entry, while preserving curated projects first and enforcing closed-year member access. Add group-aware HTML interludes, full-viewport screening, play-from-here URL recovery, and automatic continuation past broken clips on top of the existing preloaded player.
Render reel entries with the same project row, group tag, and contributor stack used by the overview list, with the row title starting playback from that project. Remove the implementation-detail playback copy and the now-redundant reel-specific card styling.
Drop the benchmark, processor fixture runner, local readiness journey, and rollout E2E script from the pull request. Simplify the verification command and documentation accordingly, and remove the rollout runbook from the repository.
Limit the large reel heading typography to the section heading instead of every nested project title. Keep contributor initials aligned at the end of each playlist row and add regression coverage for the shared member bubbles.
Add a controller-backed timeline that remains usable in normal and fullscreen screening modes, without exposing native fullscreen controls that bypass HTML interludes. Fade each project overlay after its introduction and return stable member IDs in playlist data so shared project rows render the same contributor bubbles as the overview.
Keep the seek timeline available in normal playback while removing the control overlay from fullscreen screening so it never obscures project video content or HTML interludes.
Treat processor capacity as durable backpressure, close the multipart pause race, and keep screening audio and error state stable across clip transitions. Separate responsive control rows and reset the player when playlist membership changes to prevent mobile overlap and stale-index crashes.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 240ff1a. Configure here.
Treat AudioContext resume as best-effort during ended and error-driven autoplay transitions so a browser-held resume promise cannot stall the reel. User-triggered start and resume actions still await audio activation, and the controller test covers a permanently pending automatic resume.
| export async function getProjectVideo(db: D1Database, projectId: string) { | ||
| const row = await videoByProject(db, projectId); | ||
| const row = await db | ||
| .prepare(`${videoSelect()} WHERE project_id = ? AND retired_at IS NULL`) | ||
| .bind(projectId) | ||
| .first<VideoRow>(); | ||
| return row ? mapVideo(row) : null; | ||
| } |
There was a problem hiding this comment.
Video metadata and content readable by any authenticated user, bypassing the submissions-closed gate
getProjectVideo performs no membership/ownership check, so any logged-in user can fetch the video ID for any project and then download the actual video via the unguarded /api/videos/:videoId/content endpoint, bypassing the 'screening reel is available after submissions close' restriction enforced in listPlaylist.
Evidence
getProjectVideo(services/videos.ts:82-88) queries byproject_idand returnsmapVideo(row)(which includes the videoid) without callingauthorizeVideoWriteor any membership check.- The route
projectVideoRoutes.get('/:projectId/video')in routes/videos.ts passes only the path param; all/api/*routes require onlyauthenticateRequest(index.ts:44), so any logged-in user can resolve any project's video ID (project IDs are listed to all users). issuePlayback/getVideoContent(services/videos.ts:160-192) call onlyrequireReadyVideo(status/key checks, no authz), and routes/videos.ts servesGET /api/videos/:videoId/contentfrom R2 with no user check.listPlaylistexplicitly gates non-admins with 403 'The screening reel is available after submissions close' (services/videos.ts:95-104), establishing that pre-close cross-team video access is a real boundary the content path does not enforce.
Identified by Warden · security-review · 5PP-XX5

Summary
Verification
npm run verifynpm audit --omit=dev