Skip to content

Epic 7 v2 — Reel Editor (lightweight clip stitcher)

Redesigned & active-pending (rethought 2026-06-09). The original Reel/ReelItem builder was removed in [[Epic 34 — Unified Talent Portfolio]]; this is the rebuilt concept agreed with the user. It is a separate, REEL_EDITOR-gated tool (flag off by default) — never gates the portfolio, which works identically with the flag on or off. The tool lets a talent assemble existing portfolio media (images + videos) plus generated title cards into a short reel with trims, text overlays, and basic transitions; on export a queued server-side ffmpeg job renders one flat MP4, watermarked with the CasTyou logo, and adds it back as a single new portfolio MediaItem (it is NOT its own storage model — Epic-34-compliant). The talent is notified when the reel is ready.

Scope (user decisions, 2026-06-09): lightweight only. Output 16:9 (default) or 9:16, 720p or 1080p. No AI auto-reel, audio mixing/ducking, filters, playback speed, orientation flips, 4K, 1:1/4:5, or custom social ShareSheet. Server-side render via fluent-ffmpeg (already a dependency); processing runs through a BullMQ queue (mirrors castyou-backend/src/services/casting/queue.ts) so it is resource-managed and runs in the background — the user does not keep the page open.

Infra already present (reuse, do not add): bullmq + ioredis (queue), fluent-ffmpeg + @ffmpeg-installer/ffmpeg (render), services/notifications createNotification (ready alert), getMediaUploadUrl R2 presign in resolvers/portfolio.ts, casting/queue.ts as the queue blueprint.


Clip model (polymorphic)

A ReelDraft is an ordered list of clips; each clip is one of three kinds, and any clip may carry a text overlay + transition:

KindSourceKind-specific fields
VIDEOportfolio MediaItem (type: video)trimStart, trimEnd
IMAGEportfolio MediaItem (type: image)durationSec (default 4s)
TITLE_CARDgenerated (no media)text, durationSec (default 4s), bgColor

Shared per-clip: order, optional overlayText + overlayPosition (TOP/CENTER/BOTTOM) + fontFamily + fontSize, transitionIn (CUT/FADE/CROSS_DISSOLVE). Title cards use the same font/size pickers as overlays. Referenced MediaItems must belong to the editing talent.


BE-REEL-001 — ReelDraft / ReelDraftClip manifest models + CRUD

  • [x] Implemented

Files:

  • Edit: castyou-backend/prisma/schema.prisma — add ReelDraft + ReelDraftClip
  • Run: pnpm db:migrate (Postgres schema change — prisma generate alone is not enough)
  • Edit: castyou-backend/src/graphql/schema/index.ts — types + mutations below
  • Create: castyou-backend/src/graphql/resolvers/reel.ts — own-profile auth; validates referenced MediaItems belong to the talent
  • Create: castyou-backend/src/__tests__/resolvers/reel.test.ts

Schema (manifest only — references MediaItem, stores no media):

prisma
model ReelDraft {
  id               String          @id @default(cuid())
  talentProfileId  String
  talentProfile    TalentProfile   @relation(fields: [talentProfileId], references: [id], onDelete: Cascade)
  name             String          // default generated client-side: "Reel 2026 Jan 3rd"
  aspectRatio      String          @default("16:9")    // "16:9" | "9:16"
  targetResolution String          @default("1080p")   // "720p" | "1080p"
  renderStatus     String          @default("IDLE")    // IDLE | QUEUED | RENDERING | READY | FAILED
  outputMediaItemId String?        // set when render succeeds (points at the new portfolio MediaItem)
  renderError      String?
  clips            ReelDraftClip[]
  createdAt        DateTime        @default(now())
  updatedAt        DateTime        @updatedAt
  @@map("reel_drafts")
}

model ReelDraftClip {
  id              String    @id @default(cuid())
  draftId         String
  draft           ReelDraft @relation(fields: [draftId], references: [id], onDelete: Cascade)
  order           Int
  kind            String    // "VIDEO" | "IMAGE" | "TITLE_CARD"
  mediaItemId     String?   // VIDEO / IMAGE — references MediaItem
  trimStart       Float?    // VIDEO (seconds)
  trimEnd         Float?    // VIDEO (seconds)
  durationSec     Float?    // IMAGE / TITLE_CARD (default 4)
  text            String?   // TITLE_CARD content
  bgColor         String?   // TITLE_CARD background
  overlayText     String?   // any clip — text drawn over the clip
  overlayPosition String?   // "TOP" | "CENTER" | "BOTTOM"
  fontFamily      String?   // overlay / title-card font
  fontSize        Int?      // overlay / title-card size
  transitionIn    String?   // "CUT" | "FADE" | "CROSS_DISSOLVE"
  @@map("reel_draft_clips")
}

Mutations: createReelDraft(input), updateReelDraft(id, input) (name/aspectRatio/targetResolution), addReelDraftClip(draftId, input), updateReelDraftClip(id, input), reorderReelDraftClips(draftId, clipIds: [ID!]!), deleteReelDraftClip(id), deleteReelDraft(id). Queries: myReelDrafts, reelDraft(id) (own-profile only).

Acceptance criteria:

  • Migration adds reel_drafts + reel_draft_clips; server boots clean
  • All mutations enforce own-profile auth and reject mediaItemIds not owned by the talent
  • renderStatus defaults to IDLE

BE-REEL-002 — Queued ffmpeg render service (+ logo watermark) → output MediaItem

  • [x] Implemented

Files:

  • Create: castyou-backend/src/services/reel/queue.ts — BullMQ queue/worker mirroring src/services/casting/queue.ts (dedicated Redis conn maxRetriesPerRequest: null; attempts/backoff; removeOnComplete/Fail)
  • Create: castyou-backend/src/services/reel/renderer.tsfluent-ffmpeg compositor
  • Create: castyou-backend/src/services/reel/assets/watermark.png — transparent CasTyou logo
  • Edit: castyou-backend/src/index.ts — start the reel-render worker at boot (next to startCastingAssemblyWorker)
  • Edit: castyou-backend/src/config/index.ts — add REEL_RENDER_CONCURRENCY (default 2)
  • Edit: castyou-backend/src/graphql/schema/index.ts + resolvers/reel.tsrenderReelDraft mutation
  • Create: castyou-backend/src/__tests__/services/reel.renderer.test.ts

Mutation: renderReelDraft(draftId: ID!, visibility: String = "PRIVATE"): ReelDraft! — validates the draft has ≥1 clip, sets renderStatus = QUEUED, enqueues { draftId, visibility }, and returns immediately (background processing; the client need not stay open).

Worker / renderer:

  • Flip QUEUED → RENDERING. Build one ffmpeg filter_complex:
    • VIDEOtrim (trimStart/trimEnd) → scale; keep original audio
    • IMAGE → loop to durationSec → scale; silent audio
    • TITLE_CARDcolor=bgColor source for durationSec; silent audio
    • overlaydrawtext per clip (bundled font, fontFamily/fontSize, position TOP/CENTER/BOTTOM)
    • transitionsxfade (video) + acrossfade/afade (audio) between consecutive clips
    • canvas → scale + pad/letterbox every clip to the target aspect (16:9 or 9:16) and resolution (720p → 1280×720 / 720×1280; 1080p → 1920×1080 / 1080×1920)
    • watermark → final overlay of watermark.png bottom-right, ~12% of frame width, ~3% edge margin (burned into the MP4; persists on download/share)
  • Upload the MP4 to R2 (reuse the portfolio R2 path), create a new MediaItem (type: video, the chosen visibility, title = draft name), set outputMediaItemId + renderStatus = READY. On error: renderStatus = FAILED, store renderError (worker is the only place that sets FAILED — same contract as casting/queue.ts).

Acceptance criteria:

  • Renders 16:9 & 9:16 at 720p/1080p with correct letterboxing; mixed image/video/title clips concat with transitions
  • Logo watermark bottom-right on every output
  • Worker concurrency honors REEL_RENDER_CONCURRENCY (default 2)
  • Success creates exactly one portfolio MediaItem with the selected visibility (default PRIVATE)

Future (not v2): tier-gated watermark removal (e.g. Epic 12 Agency tier or a CasTars unlock) — the watermark is baked at render time, so this is a render-flag toggle later.


BE-REEL-003 — REEL_READY / REEL_FAILED notifications + admin failure surfacing

  • [x] Implemented

Files:

  • Edit: castyou-backend/src/models/mongo/Notification.ts — add 'REEL_READY' + 'REEL_FAILED' to NotificationType
  • Edit: castyou-backend/src/services/reel/queue.ts — on READY, call createNotification; on FAILED (markFailed), notify the talent AND log to the admin ErrorLog

Description: When a render completes the worker fires createNotification(userId, 'REEL_READY', { mediaItemId, reelName }); the talent is alerted the reel is ready in their portfolio. On terminal failure (markFailed, after BullMQ exhausts retries) the worker also surfaces the error two ways:

  • To the talentcreateNotification(userId, 'REEL_FAILED', { draftId, reelName, error }) so they see it even after closing the editor (the editor additionally shows renderError inline, and the portfolio shows a failed card with retry/discard).
  • To adminslogClientError({ type: 'RUNTIME_ERROR', route: 'reel-render', extra: { scope: 'REEL_RENDER', draftId } }) into the shared Epic 8 ErrorLog, so render failures appear on the existing admin error dashboard (deduped/fingerprinted like any other error).

Both are notifications, not messages (notifications-vs-messages rule).

Acceptance criteria:

  • Successful render produces a REEL_READY notification with a working deep-link to the new portfolio item
  • Terminal failure produces a REEL_FAILED talent notification AND an admin ErrorLog entry (scope REEL_RENDER)

FE-REEL-001 — Full-screen responsive reel editor (flag-gated)

  • [x] Implemented

Files:

  • Create: apps/app/src/pages/reel/ReelEditorPage.tsx — full-screen, rendered outside AppShell (no navbar, no sidebar)
  • Create: apps/app/src/hooks/useReelDraft.ts
  • Create: apps/app/src/components/reel/TransitionPicker.tsx (→ move to DS per Epic 0 table)
  • Edit: apps/app/src/App.tsx — add routes /reel/new, /reel/:id/edit, wrapped in <FeatureGate flag="REEL_EDITOR">

Description: Immersive, desktop + mobile reel editor (DS components only):

  • Name — editable text field, default generated client-side as Reel {YYYY} {Mon} {Do} (e.g. "Reel 2026 Jan 3rd").
  • Reel settings — aspect-ratio toggle (16:9 default / 9:16), resolution toggle (720p / 1080p).
  • Add media — picker over the talent's existing portfolio MediaItems (images + videos); "Add title card" button for generated text cards. No upload here — uploads happen in the portfolio.
  • Timeline — drag-and-drop reorder (@dnd-kit/core); per-clip controls: trim start/end (VIDEO), duration (IMAGE/TITLE_CARD, default 4s), overlay text + position + font + size pickers, transition picker, title-card text + bgColor.
  • Layout — desktop: media picker · timeline · clip inspector (3 zones); mobile: stacked with a bottom clip strip + sheet-based inspector.
  • Entry points (e.g. a "Create reel" action in the portfolio) are hidden when REEL_EDITOR is OFF.

FE-REEL-002 — Export flow: visibility, queued render, background-processing UX

  • [x] Implemented

Files:

  • Edit: apps/app/src/pages/reel/ReelEditorPage.tsx — export action + status surface
  • Edit: apps/app/src/hooks/useReelDraft.tsrenderStatus polling

Description:

  • Export opens a visibility selector (Public / Private, default Private) → calls renderReelDraft(draftId, visibility).
  • Because rendering is queued and runs in the background, show a "Your reel is processing — we'll notify you when it's ready, you can close this" state. The user does not need to keep the page open.
  • If the user stays, optionally poll renderStatus; on READY link to the new portfolio item, on FAILED show renderError with a retry.
  • The REEL_READY notification (BE-REEL-003) + the item appearing in the portfolio are the canonical completion signals.
  • Processing reels appear in the portfolio: the owner's portfolio lists any QUEUED/RENDERING draft as a non-destructive "Processing reel" placeholder card (via useReelDrafts, which polls while a render is in flight). When a render completes the placeholder is replaced by the real output MediaItemuseReelDrafts detects the transition and invalidates+refetches the portfolio so the finished video lands without a manual reload. Placeholders are owner-only (client-side merge); the public profile only ever shows real MediaItems.
  • Save without exporting / resume later: the editor already autosaves every change (name, clips, reorder, aspect/resolution persist immediately via the BE-REEL-001 mutations), so no data is lost on close. Added an explicit "Save draft" button in the editor header (toast + returns to /portfolio) for reassurance, and surfaced non-empty IDLE/READY drafts in the portfolio as "Reel draft" / "Exported reel" cards (via useReelDrafts.editable) with "Continue editing" (→ /reel/:id/edit) + "Discard". Empty drafts (a /reel/new opened then abandoned) are filtered out. This closes the resume-editing gap — a saved draft is now findable and re-openable.

BE-REEL-004 / FE-REEL-003 — Admin reel-render monitoring

  • [x] Implemented

Backend files:

  • Edit: castyou-backend/src/graphql/schema/index.tsAdminReelDraft / AdminReelDraftPage / ReelQueueCounts / AdminReelStats types; adminReelDrafts(page, pageSize, status) + adminReelStats queries
  • Edit: castyou-backend/src/graphql/resolvers/reel.tsrequireAdmin queries; paginated projection (talent name/handle/userId + clip count) and status aggregation via groupBy; live BullMQ getJobCounts() (best-effort, null if Redis down)

Frontend files:

  • Create: apps/app/src/lib/queries/adminReel.ts, apps/app/src/hooks/useAdminReels.ts, apps/app/src/pages/admin/AdminReelsPage.tsx
  • Edit: apps/app/src/App.tsx (/admin/reels route), apps/app/src/components/AdminShell.tsx (nav entry), packages/i18n/locales/{en,pt,es}/app.json (adminShell.nav.reels)

Description: Admin dashboard at /admin/reels to monitor reel-render projects + queue health: status stat cards (total / queued / rendering / ready / failed / last-24h), a render-queue line (waiting / active / delayed / failed / completed from BullMQ), a status filter, and a paginated list of every reel project (talent, clip count, aspect·resolution, updated-at, renderError for failures, output-saved marker). Auto-refreshes every 5s while any render is in flight so admins see progress live. Complements the per-failure ErrorLog entries from BE-REEL-003.

Acceptance criteria:

  • adminReelDrafts / adminReelStats are admin-only (reject non-admins)
  • List paginates and filters by renderStatus; stats aggregate all drafts + show live queue counts when Redis is reachable