Appearance
Epic 14 — Casting Submission (Structured Auditions)
🔵 REDESIGNED → see Epic 49 — On-System Casting (designed 2026-06-29). The fresh product + technical design now lives in Epic 49; the tickets below are historical reference for the removed build only and are not the planned path. Original removal (2026-06-09) record retained below.
Removal record (for context): All Casting Submission application code was deleted from
castyou-backendandcastyou-frontend— resolvers, services (FFmpeg assembly, BullMQ queue, Whisper/GPT), GraphQL schema types/queries/mutations, theCASTING_SUBMISSIONfeature flag, and every frontend page/route/hook/component (map builder, talent wizard, producer review, request composer inJobDetailPage, wizard step inJobCreatePage). The Prisma models and tables are intentionally retained (CastingMap,CastingStep,CastingSubmission,CastingStepResponse) — no destructive migration was run. The last pre-removal code is at backendc7967f2/ frontenddbc6c99. Backendtsc+ full vitest (820 tests) and frontendtsc+ affected tests all pass post-removal.Next step: produce a new design (product flow + architecture) for structured auditions before re-scoping tickets. The "✅ Implemented" status on the tickets below reflects the pre-removal build and should not be read as current state.
The Casting Submission system lets producers attach a cast map (an ordered list of question steps) to any job. Talent applicants answer each step via webcam video, file upload, or text. All responses are assembled server-side into a single final video with a branded CasTyou intro, outro, and watermark. The submission is accompanied by an AI-generated TLDR and full text summary that the producer reads alongside the video.
DS-CASTING-001 — VideoRecorder component (Design System)
- [x] Implemented
Files:
- Create:
packages/design-system/src/components/ui/VideoRecorder.tsx - Edit:
packages/design-system/src/index.ts
Description: Browser-based webcam recorder using the MediaRecorder API. Features: live camera preview, configurable 3-2-1 countdown, recording indicator with elapsed time, stop button, inline playback review, re-record button. Auto-stops when maxDuration is reached. Returns recorded media via onRecorded(blob: Blob, durationSec: number). Shows a camera-permission-denied state gracefully. This component owns the raw camera/recorder logic — all surrounding chrome (buttons, indicators) uses DS Button and Badge.
Props: maxDuration?: number, onRecorded: (blob: Blob, durationSec: number) => void, onError?: (err: string) => void, className?
DS-CASTING-002 — StepIndicator component (Design System)
- [x] Implemented
Files:
- Create:
packages/design-system/src/components/ui/StepIndicator.tsx - Edit:
packages/design-system/src/index.ts
Description: Horizontal or vertical step indicator for multi-step wizard flows. Shows step number, short label, and completion state (pending / active / complete / error) via cva variants. Used in the casting submission wizard and any future wizard flows.
Props: steps: { label: string; description?: string }[], currentStep: number, orientation?: 'horizontal' | 'vertical', className?
BE-CASTING-001 — CastingMap & CastingStep models
- [x] Implemented
Files:
- Edit:
prisma/schema.prisma— addCastingMap,CastingStepmodels +CastingResponseTypeenum; addcastingMap CastingMap?relation toJob - Run:
pnpm db:migrate - Edit:
src/graphql/schema/index.ts— addCastingMap,CastingSteptypes + CRUD - Create:
src/graphql/resolvers/casting.ts - Edit:
src/graphql/resolvers/index.ts - Create:
src/services/casting/map.ts - Create:
src/__tests__/resolvers/casting.test.ts
Schema:
prisma
model CastingMap {
id String @id @default(cuid())
jobId String @unique
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
steps CastingStep[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("casting_maps")
}
model CastingStep {
id String @id @default(cuid())
mapId String
map CastingMap @relation(fields: [mapId], references: [id], onDelete: Cascade)
order Int
question String
description String?
allowedTypes CastingResponseType[]
maxDuration Int? // seconds — VIDEO responses only
required Boolean @default(true)
responses CastingStepResponse[]
@@map("casting_steps")
}
enum CastingResponseType {
VIDEO // recorded via webcam (DS VideoRecorder)
UPLOAD // talent uploads an existing video/audio file
TEXT // plain text response
}Add to Job model:
prisma
castingMap CastingMap?GraphQL (producer-only mutations):
graphql
createCastingMap(jobId: ID!, steps: [CastingStepInput!]!): CastingMap!
updateCastingMap(jobId: ID!, steps: [CastingStepInput!]!): CastingMap!
deleteCastingMap(jobId: ID!): Boolean!graphql
input CastingStepInput {
order: Int!
question: String!
description: String
allowedTypes: [CastingResponseType!]!
maxDuration: Int
required: Boolean
}GraphQL (any authenticated user):
graphql
castingMap(jobId: ID!): CastingMapAcceptance criteria:
- Only the job's producer can create/update/delete the casting map
- Steps always returned ordered by
order - Deleting a job cascades to casting map and all submissions
- Minimum 1 step; minimum 1
allowedTypeper step;questionnon-empty
BE-CASTING-002 — CastingSubmission & CastingStepResponse models
- [x] Implemented
Files:
- Edit:
prisma/schema.prisma— addCastingSubmission,CastingStepResponse,SubmissionStatusenum; addsubmission CastingSubmission?toApplication - Run:
pnpm db:migrate - Edit:
src/graphql/schema/index.ts - Edit:
src/graphql/resolvers/casting.ts - Create:
src/services/casting/submission.ts - Edit:
src/__tests__/resolvers/casting.test.ts
Schema:
prisma
model CastingSubmission {
id String @id @default(cuid())
jobId String
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
talentId String
talent TalentProfile @relation(fields: [talentId], references: [id], onDelete: Cascade)
applicationId String? @unique
application Application? @relation(fields: [applicationId], references: [id])
responses CastingStepResponse[]
status SubmissionStatus @default(DRAFT)
finalVideoUrl String? // assembled video in S3
tldr String? // 2-3 sentence AI summary
summary String? // full structured AI summary
assemblyJobId String? // BullMQ job ID for tracking
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([jobId, talentId])
@@map("casting_submissions")
}
model CastingStepResponse {
id String @id @default(cuid())
submissionId String
submission CastingSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
stepId String
step CastingStep @relation(fields: [stepId], references: [id])
responseType CastingResponseType
textResponse String?
mediaUrl String? // S3 CDN URL (VIDEO or UPLOAD)
duration Float? // seconds (video only)
transcript String? // Whisper-generated transcript
createdAt DateTime @default(now())
@@map("casting_step_responses")
}
enum SubmissionStatus {
DRAFT // talent filling in the wizard
SUBMITTED // all responses saved; assembly queued
ASSEMBLING // FFmpeg worker building the video
READY // final video + summary available
FAILED // assembly or transcription error
}Add to Application model:
prisma
submission CastingSubmission?GraphQL mutations (talent-only):
graphql
startCastingSubmission(jobId: ID!): CastingSubmission!
saveCastingStepResponse(
submissionId: ID!
stepId: ID!
responseType: CastingResponseType!
textResponse: String
mediaUrl: String
duration: Float
): CastingStepResponse!
submitCasting(submissionId: ID!): CastingSubmission!GraphQL queries:
graphql
myCastingSubmission(jobId: ID!): CastingSubmission
castingSubmissions(jobId: ID!, page: Int, pageSize: Int): CastingSubmissionPage!
castingSubmission(id: ID!): CastingSubmissionAcceptance criteria:
startCastingSubmissionreturns the existing DRAFT if one already exists for that (job, talent) pairsubmitCastingvalidates allrequiredsteps have a response before accepting; throwsVALIDATION_ERRORotherwise- Producer reads all submissions for their jobs; talent reads only their own (
FORBIDDENotherwise) mediaUrlonsaveCastingStepResponsemust be a confirmed S3 CDN URL, not an arbitrary URL- Pagination on
castingSubmissionsfollows the standardpage/pageSize+pageobject pattern
BE-CASTING-003 — Video assembly pipeline (FFmpeg + BullMQ)
- [x] Implemented
Files:
- Create:
src/services/casting/assembly.ts— FFmpeg orchestration, S3 download/upload - Create:
src/services/casting/queue.ts— BullMQ queue definition + worker - Edit:
src/index.ts— start BullMQ worker on app boot - Edit:
src/graphql/resolvers/casting.ts—submitCastingenqueues job after validation - Create:
src/__tests__/services/casting.assembly.test.ts
Dependencies to add:
bash
npm install bullmq fluent-ffmpeg @ffmpeg-installer/ffmpeg
npm install --save-dev @types/fluent-ffmpegStatic assets (stored in S3 castyou-media/assets/):
intro.mp4— clapperboard animation (3–5 s)outro.mp4— CasTyou branded TikTok-style ending (3–5 s)watermark.png— CasTyou logo at ~15 % opacity, 150 px wide
Assembly algorithm:
For each submission, download to /tmp/{submissionId}/:
1. intro.mp4 (from S3 assets)
2. For each CastingStep (ordered):
a. question_card_{i}.mp4 — generated by FFmpeg drawtext from step.question
(5 s black-background slate with white text)
b. answer_{i}.mp4:
VIDEO or UPLOAD → response.mediaUrl downloaded from S3
TEXT → FFmpeg drawtext slate from textResponse
(duration = max(5, wordCount / 2.5) seconds)
3. outro.mp4 (from S3 assets)
4. watermark.png
FFmpeg concat + watermark in one pass:
ffmpeg \
-i intro.mp4 -i q1.mp4 -i a1.mp4 ... -i outro.mp4 -i watermark.png \
-filter_complex "
[0][1][2]...[N]concat=n=N:v=1:a=1[catv][cata];
[catv][Nw]overlay=W-w-20:H-h-20[finalv]
" \
-map "[finalv]" -map "[cata]" \
-c:v libx264 -crf 23 -preset fast -c:a aac \
output.mp4
Upload output.mp4 → S3: castyou-media/casting/{submissionId}/final.mp4Queue flow:
submitCastingsetsstatus = SUBMITTED, enqueues{ submissionId }oncasting-assemblyqueue, storesassemblyJobId- Worker picks up job: sets
status = ASSEMBLING, runs assembly, uploads to S3 - On success: sets
finalVideoUrl, triggers BE-CASTING-004 (transcription + summarisation), then setsstatus = READY - On failure: sets
status = FAILED; sends notification to talent and producer
Infrastructure notes:
@ffmpeg-installer/ffmpegbundles a static FFmpeg binary (no system install required)- All temp files in
/tmp/{submissionId}/are deleted after assembly (success or failure) - BullMQ uses the existing Redis (
ioredis) connection fromsrc/config/redis.ts - Retry policy: 3 attempts with exponential backoff on transient errors
Acceptance criteria:
- Assembled video contains: intro → (question card → answer) × N → outro
- CasTyou watermark visible bottom-right on all clips (including question cards and answer slates)
- TEXT responses rendered as readable text-slide video
- Temp files cleaned up after every run
- Failed assemblies set
status = FAILEDand do not leave orphan temp files
BE-CASTING-004 — AI transcription (Whisper) & summarisation (GPT-4o)
- [x] Implemented
Files:
- Create:
src/services/casting/transcription.ts— OpenAI Whisper calls - Create:
src/services/casting/summarisation.ts— GPT-4o summarisation - Edit:
src/services/casting/assembly.ts— call transcription then summarisation after assembly upload - Create:
src/__tests__/services/casting.summarisation.test.ts
Transcription:
- For each VIDEO/UPLOAD response: call
openai.audio.transcriptions.create({ file, model: 'whisper-1' })using the downloaded media file - Store result on
CastingStepResponse.transcript - TEXT responses used verbatim — no Whisper call needed
Summarisation prompt (GPT-4o):
System:
You are a professional casting director assistant for CasTyou, a talent marketplace.
Summarise casting submission responses clearly and professionally.
User:
Casting submission for job: "{job.title}"
Producer: "{producerProfile.displayName}"
{for each step:
"Q{i}: {step.question}
A{i}: {response.transcript || response.textResponse}"
}
Generate exactly:
1. TLDR: 2-3 sentences capturing the talent's overall suitability,
standout qualities, and key highlights.
2. Summary: A structured breakdown of each question and the talent's response,
written in third person, highlighting communication style, relevant experience,
and casting fit. Use clear section headings per question.Output: Stored on CastingSubmission.tldr and CastingSubmission.summary. After both are saved, set status = READY.
Acceptance criteria:
- TLDR is 2–3 sentences; summary has one section per question
- If transcription fails for a step, gracefully skip that transcript (use
[transcription unavailable]placeholder) — do not fail the whole submission - If GPT-4o call fails,
statusstaysASSEMBLINGand retries (BullMQ retry policy)
FE-CASTING-001 — Casting map builder (producer — job creation wizard)
- [x] Implemented
Files:
- Create:
apps/app/src/pages/jobs/steps/CastingMapStep.tsx - Create:
apps/app/src/components/CastingStepCard.tsx— single question card with drag handle - Create:
apps/app/src/hooks/useCreateCastingMap.ts - Edit:
apps/app/src/pages/jobs/CreateJobPage.tsx— insertCastingMapStepbefore the Review step - Edit:
apps/app/src/pages/jobs/steps/JobReviewStep.tsx— show casting map summary in review - Edit:
apps/app/src/App.tsx— add/jobs/:id/casting-map/editroute for editing after posting
Description: New final step in the job creation wizard (after Compensation, before Review). The producer toggles "Require structured casting submission" on/off. When on, they build the question map:
- "Add Question" button appends a new
CastingStepCard - Each card: question text (
Input, required), optional description (Textarea), allowed response types (MultiSelect: Video / Upload / Text — at least one), max video duration if VIDEO is selected (Select: 30 s / 60 s / 90 s / 2 min), required toggle - Cards are drag-and-drop reorderable via
@dnd-kit/core(same library as reel builder) StepIndicator(DS) shows current question count at the top of the step- On job save: if casting enabled, call
createCastingMapmutation immediately aftercreateJobsucceeds - The map can be edited later via
/jobs/:id/casting-map/edit(producer-only route)
Acceptance criteria:
- At least 1 step if casting is enabled; at least 1 allowed type per step
- Draft preserved in
localStorageacross page refreshes - Review step shows a read-only list of question steps when casting is enabled
- Editing an existing map calls
updateCastingMap(full replace of steps array)
FE-CASTING-002 — Casting submission wizard (talent)
- [x] Implemented
Files:
- Create:
apps/app/src/pages/casting/CastingPage.tsx— wizard container, routing logic - Create:
apps/app/src/pages/casting/steps/CastingStepScreen.tsx— one screen per question - Create:
apps/app/src/pages/casting/CastingReviewScreen.tsx— review all responses before submit - Create:
apps/app/src/pages/casting/CastingProcessingScreen.tsx— assembly in-progress state - Create:
apps/app/src/pages/casting/CastingResultScreen.tsx— final video + TLDR + summary - Create:
apps/app/src/hooks/useCastingSubmission.ts - Edit:
apps/app/src/App.tsx— add route/jobs/:id/cast - Edit:
apps/app/src/pages/jobs/JobDetailPage.tsx— show "Start Casting" button whenjob.castingMapexists
User flow:
/jobs/:id/cast
→ startCastingSubmission(jobId) [returns existing DRAFT if present]
→ StepIndicator (DS) at top showing "Step N of total"
For each CastingStep (in order):
CastingStepScreen
Shows: question title, optional description
If multiple allowedTypes: type selector chips [Record Video 🎥] [Upload File 📁] [Write Text ✍️]
VIDEO → <VideoRecorder maxDuration={step.maxDuration} /> (DS)
UPLOAD → <FileUpload accept="video/*,audio/*" /> (DS)
TEXT → <Textarea maxLength={2000} /> (DS)
On capture/input complete:
1. If media: upload to S3 via getMediaUploadUrl presigned URL, show progress bar
2. Call saveCastingStepResponse with mediaUrl or textResponse
3. Persist step state to localStorage
[Back] [Continue] — Continue disabled until response saved for required steps
CastingReviewScreen
Shows summary of each response:
VIDEO/UPLOAD → thumbnail + duration chip
TEXT → first 200 chars preview
"Edit" link per response returns to that step
[Submit Casting] → submitCasting(submissionId)
CastingProcessingScreen
"Your casting is being assembled..." + animated CasTyou logo
Polls castingSubmission(id).status every 5 s
Auto-advances to CastingResultScreen when status = READY
Shows error state with retry option if status = FAILED
CastingResultScreen
<MediaPlayer src={finalVideoUrl} /> (DS)
TLDR card (DS Card, styled with quote marks)
Full summary in expandable accordion (DS)
[Back to Jobs] buttonAcceptance criteria:
- Works on mobile (touch-friendly, responsive layout, VideoRecorder uses mobile camera)
- Media upload shows progress bar before advancing to next step
- Required steps cannot be skipped
- Review screen accurately reflects all saved responses
- Processing screen auto-advances on status change without page reload
- Draft state survives page refresh (localStorage + startCastingSubmission resume logic)
FE-CASTING-003 — Producer casting submission review
- [x] Implemented
Files:
- Edit:
apps/app/src/pages/jobs/JobApplicationsPage.tsx— add "Castings" tab whenjob.castingMapexists - Create:
apps/app/src/pages/jobs/CastingSubmissionsPage.tsx - Create:
apps/app/src/components/CastingSubmissionCard.tsx— compact card per submission - Create:
apps/app/src/hooks/useCastingSubmissions.ts - Edit:
apps/app/src/App.tsx— add route/jobs/:id/castings
Description: Producer sees a paginated list of casting submissions for a job (accessible from the "Castings" tab in job management). Each CastingSubmissionCard shows:
- Talent avatar, name, category, verified badge
- Submission status chip (
DRAFT/ASSEMBLING/READY/FAILED) - TLDR excerpt (first 120 chars, only when
READY) - [View] button → inline expand or navigate to detail showing:
<MediaPlayer finalVideoUrl>, TLDR card, full summary accordion - Application status actions (Shortlist / Reject / Hire) directly on the card
Acceptance criteria:
- Paginated (
page/pageSize) — consistent with roadmap rule ASSEMBLINGsubmissions show a spinner in place of the videoFAILEDsubmissions show a clear error state (no video, no summary)- Application status can be updated without leaving the submissions list