Appearance
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 portfolioMediaItem(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 (mirrorscastyou-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:
| Kind | Source | Kind-specific fields |
|---|---|---|
VIDEO | portfolio MediaItem (type: video) | trimStart, trimEnd |
IMAGE | portfolio MediaItem (type: image) | durationSec (default 4s) |
TITLE_CARD | generated (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— addReelDraft+ReelDraftClip - Run:
pnpm db:migrate(Postgres schema change —prisma generatealone 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 referencedMediaItems 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 renderStatusdefaults toIDLE
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 mirroringsrc/services/casting/queue.ts(dedicated Redis connmaxRetriesPerRequest: null; attempts/backoff;removeOnComplete/Fail) - Create:
castyou-backend/src/services/reel/renderer.ts—fluent-ffmpegcompositor - 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 tostartCastingAssemblyWorker) - Edit:
castyou-backend/src/config/index.ts— addREEL_RENDER_CONCURRENCY(default 2) - Edit:
castyou-backend/src/graphql/schema/index.ts+resolvers/reel.ts—renderReelDraftmutation - 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 ffmpegfilter_complex:- VIDEO →
trim(trimStart/trimEnd) → scale; keep original audio - IMAGE → loop to
durationSec→ scale; silent audio - TITLE_CARD →
color=bgColorsource fordurationSec; silent audio - overlay →
drawtextper clip (bundled font,fontFamily/fontSize, position TOP/CENTER/BOTTOM) - transitions →
xfade(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
overlayofwatermark.pngbottom-right, ~12% of frame width, ~3% edge margin (burned into the MP4; persists on download/share)
- VIDEO →
- Upload the MP4 to R2 (reuse the portfolio R2 path), create a new
MediaItem(type: video, the chosenvisibility, title = draft name), setoutputMediaItemId+renderStatus = READY. On error:renderStatus = FAILED, storerenderError(worker is the only place that sets FAILED — same contract ascasting/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
MediaItemwith 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'toNotificationType - Edit:
castyou-backend/src/services/reel/queue.ts— onREADY, callcreateNotification; onFAILED(markFailed), notify the talent AND log to the adminErrorLog
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 talent —
createNotification(userId, 'REEL_FAILED', { draftId, reelName, error })so they see it even after closing the editor (the editor additionally showsrenderErrorinline, and the portfolio shows a failed card with retry/discard). - To admins —
logClientError({ type: 'RUNTIME_ERROR', route: 'reel-render', extra: { scope: 'REEL_RENDER', draftId } })into the shared Epic 8ErrorLog, 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_READYnotification with a working deep-link to the new portfolio item - Terminal failure produces a
REEL_FAILEDtalent notification AND an adminErrorLogentry (scopeREEL_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 outsideAppShell(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_EDITORis 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.ts—renderStatuspolling
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; onREADYlink to the new portfolio item, onFAILEDshowrenderErrorwith a retry. - The
REEL_READYnotification (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/RENDERINGdraft as a non-destructive "Processing reel" placeholder card (viauseReelDrafts, which polls while a render is in flight). When a render completes the placeholder is replaced by the real outputMediaItem—useReelDraftsdetects 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 realMediaItems. - 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 (viauseReelDrafts.editable) with "Continue editing" (→/reel/:id/edit) + "Discard". Empty drafts (a/reel/newopened 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.ts—AdminReelDraft/AdminReelDraftPage/ReelQueueCounts/AdminReelStatstypes;adminReelDrafts(page, pageSize, status)+adminReelStatsqueries - Edit:
castyou-backend/src/graphql/resolvers/reel.ts—requireAdminqueries; paginated projection (talent name/handle/userId + clip count) and status aggregation viagroupBy; live BullMQgetJobCounts()(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/reelsroute),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/adminReelStatsare admin-only (reject non-admins)- List paginates and filters by
renderStatus; stats aggregate all drafts + show live queue counts when Redis is reachable