Skip to content

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-backend and castyou-frontend — resolvers, services (FFmpeg assembly, BullMQ queue, Whisper/GPT), GraphQL schema types/queries/mutations, the CASTING_SUBMISSION feature flag, and every frontend page/route/hook/component (map builder, talent wizard, producer review, request composer in JobDetailPage, wizard step in JobCreatePage). The Prisma models and tables are intentionally retained (CastingMap, CastingStep, CastingSubmission, CastingStepResponse) — no destructive migration was run. The last pre-removal code is at backend c7967f2 / frontend dbc6c99. Backend tsc + full vitest (820 tests) and frontend tsc + 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 — add CastingMap, CastingStep models + CastingResponseType enum; add castingMap CastingMap? relation to Job
  • Run: pnpm db:migrate
  • Edit: src/graphql/schema/index.ts — add CastingMap, CastingStep types + 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!): CastingMap

Acceptance 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 allowedType per step; question non-empty

BE-CASTING-002 — CastingSubmission & CastingStepResponse models

  • [x] Implemented

Files:

  • Edit: prisma/schema.prisma — add CastingSubmission, CastingStepResponse, SubmissionStatus enum; add submission CastingSubmission? to Application
  • 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!): CastingSubmission

Acceptance criteria:

  • startCastingSubmission returns the existing DRAFT if one already exists for that (job, talent) pair
  • submitCasting validates all required steps have a response before accepting; throws VALIDATION_ERROR otherwise
  • Producer reads all submissions for their jobs; talent reads only their own (FORBIDDEN otherwise)
  • mediaUrl on saveCastingStepResponse must be a confirmed S3 CDN URL, not an arbitrary URL
  • Pagination on castingSubmissions follows the standard page / pageSize + page object 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.tssubmitCasting enqueues 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-ffmpeg

Static 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.mp4

Queue flow:

  1. submitCasting sets status = SUBMITTED, enqueues { submissionId } on casting-assembly queue, stores assemblyJobId
  2. Worker picks up job: sets status = ASSEMBLING, runs assembly, uploads to S3
  3. On success: sets finalVideoUrl, triggers BE-CASTING-004 (transcription + summarisation), then sets status = READY
  4. On failure: sets status = FAILED; sends notification to talent and producer

Infrastructure notes:

  • @ffmpeg-installer/ffmpeg bundles 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 from src/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 = FAILED and 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, status stays ASSEMBLING and 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 — insert CastingMapStep before 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/edit route 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 createCastingMap mutation immediately after createJob succeeds
  • 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 localStorage across 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 when job.castingMap exists

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] button

Acceptance 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 when job.castingMap exists
  • 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
  • ASSEMBLING submissions show a spinner in place of the video
  • FAILED submissions show a clear error state (no video, no summary)
  • Application status can be updated without leaving the submissions list