openapi: 3.1.0
info:
  title: UGC Copilot Public REST API
  description: |
    Programmatic access to UGC Copilot's market research, script generation, image generation,
    and video rendering pipeline. The same backend that powers the web app at https://www.ugccopilot.ai.

    Authentication is a single bearer API key (prefix `ugc_live_`) issued from the web UI under
    **Profile → API Keys**. Pay-as-you-go: any account with a positive credit balance can call
    the API — no subscription required.

    Errors follow a uniform `{ error: { type, code, message, request_id, ... } }` shape. Use
    `Idempotency-Key` on mutating calls to dedupe retries. Long-running video renders are async
    — register a webhook to skip polling, or poll `proxyCheckVideoStatus` with the cadence
    documented in the API reference.

    See `docs/api-reference.md` for narrative documentation, recipes, and gotchas.
  version: '2026-06-10'
  contact:
    name: UGC Copilot Support
    url: https://www.ugccopilot.ai/feedback
  license:
    name: Proprietary
    url: https://www.ugccopilot.ai/terms

servers:
  - url: https://us-central1-viral-ugc-copilot.cloudfunctions.net
    description: Production

security:
  - bearerAuth: []

tags:
  - name: market-research
    description: Trending products and market analysis for an industry.
  - name: scripts
    description: AI-generated viral scripts and parsing.
  - name: images
    description: Scene image, twin avatar, and social asset generation.
  - name: video
    description: Async video render lifecycle (start, check, fetch).
  - name: post-production
    description: Stitching, text overlays, overlay suggestions.
  - name: twins
    description: Reusable AI Twin (creator persona) CRUD.
  - name: analysis
    description: Voice analysis and reference-video understanding.
  - name: library
    description: Saved video retrieval.

paths:
  /proxyFetchTopSellingProducts:
    post:
      tags: [market-research]
      summary: Fetch trending products in an industry
      description: |
        Returns 5–8 trending products with brand, price, ad-creative angle, and ideal influencer profile.
        Cost: 1 credit (first call ever on the account is free via `hasUsedFreeAnalysis` flag).
      operationId: fetchTopSellingProducts
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [industry]
              properties:
                industry:
                  type: string
                  description: Free-form industry label (e.g. "fitness", "skincare", "AI tools").
                  minLength: 1
                  maxLength: 100
      responses:
        '200':
          description: Array of products with creative metadata.
          headers:
            X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items: { $ref: '#/components/schemas/TrendingProduct' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Error' }

  /proxyFetchFullMarketAnalysis:
    post:
      tags: [market-research]
      summary: Fetch a full market analysis for an industry
      description: |
        Returns trending products, competitor angles, audience segments, and creative recommendations.
        Cost: 1 credit.
      operationId: fetchFullMarketAnalysis
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [industry]
              properties:
                industry:
                  type: string
                  minLength: 1
                  maxLength: 100
      responses:
        '200':
          description: Full market analysis result.
          headers:
            X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarketAnalysis' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateViralScript:
    post:
      tags: [scripts]
      summary: Generate a viral script
      description: |
        Returns hook variations, scenes (visual prompts + script text + sound cues), CTAs,
        and platform variations. Cost: 1 credit.
      operationId: generateViralScript
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScriptGenerateRequest' }
      responses:
        '200':
          description: Script result.
          headers:
            X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScriptResult' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Error' }

  /proxyRegenerateScriptWithTone:
    post:
      tags: [scripts]
      summary: Regenerate a script with a different tone
      description: Cost 1 credit. Provide the original script and a target tone (e.g. "humorous", "urgent", "educational").
      operationId: regenerateScriptWithTone
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [script, tone]
              properties:
                script: { $ref: '#/components/schemas/ScriptResult' }
                tone:
                  type: string
                  description: Target tone keyword.
                  minLength: 1
                  maxLength: 64
                projectMode: { $ref: '#/components/schemas/ProjectMode' }
                twinIntendedForIntimateMode:
                  type: boolean
                  description: |
                    See `ScriptGenerateRequest.twinIntendedForIntimateMode`. Carries the
                    Mature Twin voice across the regenerate-tone flow so a tone change
                    doesn't strip the intimate register.
      responses:
        '200':
          description: Regenerated script.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScriptResult' }
        default: { $ref: '#/components/responses/Error' }

  /proxyParseOwnScript:
    post:
      tags: [scripts]
      summary: Parse a user-written script into structured format
      description: |
        Takes raw script text (10–10,000 chars) and returns the structured ScriptResult shape with
        auto-generated visuals, sounds, and platform variations. Cost: 1 credit.
      operationId: parseOwnScript
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rawScript]
              properties:
                rawScript:
                  type: string
                  minLength: 10
                  maxLength: 10000
                twinDescription: { type: string }
                productDescription: { type: string }
                isFaceless: { type: boolean, default: false }
                projectMode:
                  $ref: '#/components/schemas/ProjectMode'
                audioMode:
                  type: string
                  enum: [voiceover, dialogue, background]
                  description: |
                    Audio treatment hint. `voiceover` forces all scenes to scriptType=voiceover
                    (no visible speaker); `dialogue` allows on-camera dialogue; `background` is
                    music/ambient-only. Defaults to inference from `projectMode` and script content.
                engine:
                  $ref: '#/components/schemas/Engine'
      responses:
        '200':
          description: Parsed script.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScriptResult' }
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateIdealInfluencer:
    post:
      tags: [images]
      summary: Generate a structured persona description (text-only)
      description: |
        Generates a persona profile — influencer description, image prompt, structured
        character attributes, and a character embedding — used as input to subsequent
        image generation calls. Cost: free (no per-call deduction; image generation is
        charged separately when you call `proxyGenerateImage`). Rate-limited and
        concurrency-slotted like other image-bucket endpoints.
      operationId: generateIdealInfluencer
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [genInput]
              properties:
                genInput:
                  type: object
                  description: Product/source input — at least one of url, images, or description is required.
                  properties:
                    url: { type: string, format: uri }
                    images:
                      type: array
                      items:
                        type: object
                        properties:
                          data: { type: string, description: Base64-encoded image bytes. }
                          mimeType: { type: string }
                    description: { type: string }
                isFaceless: { type: boolean, default: false }
                influencerGender: { type: string, enum: [male, female, non-binary] }
                influencerAge: { type: string, enum: ['18-25', '26-35', '36-45', '46-55', '55+'] }
                influencerEthnicity: { type: string, enum: [black, east-asian, south-asian, hispanic-latino, middle-eastern, white, mixed-race] }
                influencerStyle: { type: string, enum: [casual, professional, athletic, glamorous, streetwear, bohemian] }
                userInfluencerDescription:
                  type: string
                  minLength: 10
                  maxLength: 2000
                  description: |
                    Free-text persona override. When present it becomes the canonical persona
                    definition and the steering enums above are ignored.
                twinDescription:
                  type: string
                  description: |
                    When generating for an existing AI Twin, the twin's description — structured
                    character attributes are parsed from this instead of the AI-generated persona.
                projectMode: { $ref: '#/components/schemas/ProjectMode' }
                contentPreset:
                  type: object
                  properties:
                    environment: { type: string }
                    tone: { type: string }
                    setting: { type: string }
                analysisResult: { type: object, description: Optional market-analysis context from proxyFetchFullMarketAnalysis. }
                videoAnalysis: { type: object, description: Optional reference-video analysis context from proxyAnalyzeReferenceVideo. }
      responses:
        '200':
          description: Structured persona profile (text only — no image is generated).
          content:
            application/json:
              schema:
                type: object
                properties:
                  productDescription: { type: string }
                  influencerDescription: { type: string }
                  imagePrompt: { type: string }
                  voiceDescription: { type: string }
                  suggestedInteraction: { type: string }
                  structuredCharacterAttributes:
                    type: object
                    description: Parsed visual attributes (gender, ageRange, hairColor, ...) for cross-engine character consistency. Omitted in faceless mode.
                  characterEmbedding:
                    type: array
                    items: { type: number }
                    description: Embedding vector for cross-session character retrieval. Omitted in faceless mode.
                additionalProperties: true
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateImage:
    post:
      tags: [images]
      summary: Generate a product or scene image
      description: |
        Cost: 1 credit (standard) / 2 credits (HQ). First image generation ever on the account
        is free via `hasUsedFreeImageGen` flag.
      operationId: generateImage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt:
                  type: string
                  minLength: 1
                  maxLength: 4000
                quality:
                  type: string
                  enum: [standard, hq]
                  default: standard
                aspectRatio:
                  type: string
                  enum: ['1:1', '9:16', '16:9', '4:5']
                  default: '9:16'
                referenceImageUrl:
                  type: string
                  format: uri
                  description: Optional reference image (e.g. product photo) to guide generation.
      responses:
        '200':
          description: Permanent Firebase Storage URL for the generated image (JSON-encoded string).
          content:
            application/json:
              schema:
                type: string
                format: uri
                description: HTTPS URL to the generated image hosted on Firebase Storage.
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateSceneImage:
    post:
      tags: [images]
      summary: Generate an image for a specific scene
      description: |
        Tied to a script's scene context. Cost: 1 credit (standard) / 2 credits (HQ).
      operationId: generateSceneImage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [visualPrompt]
              properties:
                visualPrompt: { type: string, minLength: 1, maxLength: 4000 }
                quality: { type: string, enum: [standard, hq], default: standard }
                aspectRatio: { type: string, enum: ['1:1', '9:16', '16:9', '4:5'], default: '9:16' }
                influencerImageUrl: { type: string, format: uri }
                productImageUrl: { type: string, format: uri }
                referenceImageUrl: { type: string, format: uri }
                cloneStyleReferenceImageUrl:
                  type: string
                  format: uri
                  description: |
                    Optional for `projectMode === 'clone-video'`. URL of a frame extracted
                    from the source video being cloned. Used as a style/composition
                    reference only — the backend generates a NEW image matched to the
                    scene's visualPrompt while inheriting the keyframe's framing,
                    lighting, palette, and aesthetic. The reference is NOT reproduced as
                    the final image. When omitted, falls back to a text-only directive
                    nudging the model to recreate the reference environment from the
                    visual prompt.
                projectMode: { $ref: '#/components/schemas/ProjectMode' }
                masterIdentityPrompt:
                  type: string
                  maxLength: 2000
                  description: |
                    Optional canonical "actor playing the role" description for cross-scene
                    character consistency — face DNA, hair, body proportions, signature
                    visual markers. No clothing, no scene context. When supplied, the
                    backend injects this verbatim as an `[IDENTITY]` block at the top of
                    the Gemini prompt so the same face/body appears across every scene
                    image generated for this character. Skipped automatically in faceless
                    mode (no face to lock) and in product-only compositions (no person in
                    the frame). Cap is 2000 characters; backticks and `[IDENTITY ...]` /
                    `[/IDENTITY]` delimiters are stripped server-side to prevent
                    prompt-injection escapes.
                broadcastGraphics:
                  allOf:
                    - $ref: '#/components/schemas/BroadcastGraphics'
                  description: |
                    Optional for `projectMode === 'live-broadcast'`. When supplied, the matchup
                    is composited into the scene image as a score bug, lower-third chyron, and
                    bottom ticker for every engine that accepts a reference image (kling, veo,
                    sora, seedance) — Kling and Veo reproduce overlay text most faithfully, but
                    Sora and Seedance now ship the same graphics package instead of a blank
                    background. When omitted, the image model invents a plausible fictional
                    matchup. The same matchup is reused for every scene to keep score-bug /
                    chyron / ticker consistent across the project.
      responses:
        '200':
          description: Permanent Firebase Storage URL for the generated scene image (JSON-encoded string).
          content:
            application/json:
              schema:
                type: string
                format: uri
                description: HTTPS URL to the generated scene image hosted on Firebase Storage.
        default: { $ref: '#/components/responses/Error' }

  /proxyEditSceneImage:
    post:
      tags: [images]
      summary: Edit an existing scene image
      description: Modify an existing image with a feedback prompt (e.g. "make the background darker").
      operationId: editSceneImage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [existingImageUrl, editPrompt]
              properties:
                existingImageUrl: { type: string, format: uri }
                editPrompt: { type: string, minLength: 1, maxLength: 2000 }
                quality: { type: string, enum: [standard, hq], default: standard }
      responses:
        '200':
          description: Permanent Firebase Storage URL for the edited image (JSON-encoded string).
          content:
            application/json:
              schema:
                type: string
                format: uri
                description: HTTPS URL to the edited image hosted on Firebase Storage.
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateTwinImage:
    post:
      tags: [images]
      summary: Generate or edit an AI Twin avatar
      description: |
        Two modes — generate from reference photos, or edit an existing twin image with feedback.
        Cost: 2 credits (always HQ for face consistency).
      operationId: generateTwinImage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                twinDescription: { type: string }
                nicheTopics: { type: array, items: { type: string } }
                contentPillars: { type: array, items: { type: string } }
                voiceDescription: { type: string }
                ethnicity: { type: string }
                gender: { type: string }
                ageRange: { type: string }
                referenceImageUrls:
                  type: array
                  items: { type: string, format: uri }
                  description: Required for generation mode (1–5 reference photos of the creator).
                existingImageUrl:
                  type: string
                  format: uri
                  description: For edit mode — the existing twin image to modify.
                editPrompt:
                  type: string
                  description: For edit mode — feedback like "make hair shorter".
                intendedForIntimateMode:
                  type: boolean
                  description: |
                    When `true`, the avatar is rendered with the Mature Mode (creator-intimate)
                    prompt envelope — editorial-boudoir framing, intimate-wardrobe wardrobe cues,
                    platform-safe (suggestive-not-explicit) guardrails. Requires the same access
                    gate as creator-intimate projects: paid entitlement (active subscription OR
                    `bonusCredits > 0`) plus `hasAcceptedMatureModeTerms === true`. Returns
                    `mature_mode_paid_required` / `mature_mode_terms_required` when the gate
                    fails. Optional — defaults to standard avatar generation.
      responses:
        '200':
          description: Permanent Firebase Storage URL for the generated twin image (JSON-encoded string).
          content:
            application/json:
              schema:
                type: string
                format: uri
                description: HTTPS URL to the generated twin avatar hosted on Firebase Storage.
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateSocialImage:
    post:
      tags: [images]
      summary: Generate a social media lifestyle photo of an AI Twin
      description: |
        Renders a single hyper-realistic social-style photo of an AI Twin in a user-described
        scenario. The Twin's face is preserved across renders by feeding the persisted
        reference photos (`referenceImageUrls`) back through Gemini as subject references.

        **Two modes**: initial generation (`scenario` required) or edit (`existingImageUrl` +
        `editPrompt` required). Either path may also accept an optional `inspirationImage` whose
        pose / composition / framing / lighting is recreated using the Twin's face from the
        reference photos.

        **Cost**: 1 credit (`standard`) or 2 credits (`hq`).

        **Image limits**: Inline images (`inspirationImage`) are validated against a 5 MB
        base64-decoded size cap and standard image MIME types (jpeg, png, webp).

        **Watermark**: Users without paid entitlement (no active subscription AND `bonusCredits === 0`)
        receive a watermarked image. Paid plans and PAYG credit-pack users get a clean output.
        The watermark is applied at render time; the returned URL is final either way.
      operationId: generateSocialImage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                Either (a) `twinDescription` + `scenario` for initial generation, or (b)
                `existingImageUrl` + `editPrompt` for an edit pass over a previously generated image.
              properties:
                twinDescription:
                  type: string
                  description: Free-text description of the Twin (vibe + physical appearance). Required for initial generation.
                scenario:
                  type: string
                  description: Free-text description of the scene to render. Required for initial generation.
                aspectRatio:
                  type: string
                  enum: ['1:1', '9:16', '4:5']
                  default: '1:1'
                  description: Aspect ratio. 1:1 default (square) suits Instagram / OnlyFans / Fanvue feeds; 4:5 portrait for IG feed; 9:16 for stories / reels.
                referenceImageUrls:
                  type: array
                  description: HTTPS URLs of the Twin's reference photos for face / appearance consistency. Up to 5 are fetched.
                  items: { type: string, format: uri }
                ethnicity: { type: string, description: Optional persona attribute that biases the persona generation when reference photos are sparse. }
                gender: { type: string, description: Optional persona attribute. }
                ageRange: { type: string, description: Optional persona attribute (e.g. "mid 20s"). }
                quality: { type: string, enum: [standard, hq], default: standard }
                existingImageUrl:
                  type: string
                  format: uri
                  description: HTTPS URL of a previously generated image. Required (with `editPrompt`) for the edit path.
                editPrompt:
                  type: string
                  description: User feedback that drives the edit ("make it brighter", "swap to a coffee shop"). Required (with `existingImageUrl`) for the edit path.
                inspirationImage:
                  type: object
                  description: Optional inline reference photo whose pose / composition / framing / lighting Gemini recreates. The face / identity always comes from `referenceImageUrls` (or `twinDescription` if no photos), never from this image. Subject to a 5 MB base64-decoded size cap.
                  required: [data, mimeType]
                  properties:
                    data:
                      type: string
                      description: Base64-encoded image bytes (no `data:` URL prefix).
                    mimeType:
                      type: string
                      enum: [image/jpeg, image/png, image/webp]
                includeTextOverlay:
                  type: boolean
                  description: When true, Gemini renders the text from `textOverlayContent` directly onto the image.
                textOverlayContent:
                  type: string
                  description: Text to overlay when `includeTextOverlay` is true.
                projectMode:
                  $ref: '#/components/schemas/ProjectMode'
                  description: |
                    When set to `creator-intimate`, the image is rendered with the Mature Mode
                    prompt envelope — editorial-boudoir framing, intimate-wardrobe wardrobe cues,
                    platform-safe (suggestive-not-explicit) guardrails. Requires the same access
                    gate as creator-intimate projects: paid entitlement (active subscription OR
                    `bonusCredits > 0`) plus `hasAcceptedMatureModeTerms === true`. Returns
                    `mature_mode_paid_required` / `mature_mode_terms_required` when the gate
                    fails. Other ProjectMode values are accepted but have no effect on this
                    endpoint today. Optional — defaults to standard social-image generation.
      responses:
        '200':
          description: Permanent Firebase Storage URL for the generated social image (JSON-encoded string).
          content:
            application/json:
              schema:
                type: string
                format: uri
                description: HTTPS URL to the generated social-platform image hosted on Firebase Storage.
        default: { $ref: '#/components/responses/Error' }

  /proxyStartVideoGeneration:
    post:
      tags: [video]
      summary: Start an asynchronous video render
      description: |
        **Credits are deducted at this call.** Returns an `operation` reference that you poll with
        `proxyCheckVideoStatus`, OR receive via webhook (`video.completed` / `video.failed`).

        Cost is engine-specific via `getVideoCreditCost(engine, quality, duration)` — see the
        `Pricing reference` in `docs/api-reference.md`. For 8s renders: Sora std=18, Sora HQ=65,
        Veo std=40, Veo HQ=130, Kling std=40, Kling HQ=63, Kling 4K=163, Seedance std=36, Seedance HQ=70.
        Veo is fixed-cost per render regardless of duration; the others scale linearly.

        **Strongly recommend** sending `Idempotency-Key` to prevent network-retry double-charges.
      operationId: startVideoGeneration
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StartVideoRequest' }
      responses:
        '200':
          description: Operation handle for polling.
          headers:
            X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema:
                type: object
                required: [operation, effectiveDuration, creditCost, quality]
                properties:
                  operation:
                    type: object
                    required: [name]
                    properties:
                      name:
                        type: string
                        description: |
                          Opaque, engine-specific operation identifier. Pass back verbatim
                          to `proxyCheckVideoStatus` and `proxyFetchVideo` along with the
                          `engine` field — never parse this string.
                  assembledPrompt:
                    type: [string, 'null']
                    description: The final prompt sent to the engine after server-side post-processing.
                  requestedDuration:
                    type: [integer, 'null']
                    description: |
                      The `duration` value the caller passed in, or `null` if omitted.
                      Compare against `effectiveDuration` to detect when the backend snapped
                      the request to the engine's allowed duration set.
                  effectiveDuration:
                    type: integer
                    description: |
                      The actual render duration in seconds after engine-specific snapping/clamping.
                      Sora snaps to [4,8,12,16,20]; Veo clamps to [4,6,8] and forces 8 for 1080p;
                      Kling clamps to [3,15]; Seedance clamps to [4,15]. This value drives both
                      the upstream render and the credit cost.
                  creditCost:
                    type: integer
                    description: Credits deducted from the user's balance for this render.
                  quality:
                    type: string
                    enum: [standard, hq, ultra]
                    description: |
                      Quality tier derived from the chosen `modelName`. `ultra` is Kling 4K only;
                      `hq` is sora-2-pro / veo-3.1-generate-preview / Kling Pro / Seedance HQ /
                      Motion Control Pro; everything else maps to `standard`.
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '422': { $ref: '#/components/responses/IdempotencyConflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Error' }

  /proxyCheckVideoStatus:
    post:
      tags: [video]
      summary: Poll a video render's status
      description: |
        **No credits.** Returns `{ done: false }` while in progress (with optional `progress` 0–100
        for Sora/Kling/Seedance). On completion: `{ done: true, response: { generatedVideos: [...] } }`.
        On failure: `{ done: true, error: { code, message } }` — credits auto-refunded.

        Suggested cadence: 15s start, ×1.2 backoff up to 60s for standard renders; 30s start for HQ.
      operationId: checkVideoStatus
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operationName, engine]
              properties:
                operationName: { type: string }
                engine: { $ref: '#/components/schemas/Engine' }
      responses:
        '200':
          description: Operation status.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required: [done]
                    properties:
                      done: { type: boolean, enum: [false] }
                      progress:
                        type: integer
                        minimum: 0
                        maximum: 100
                        description: 'Best-effort progress percentage. Only emitted by Sora/Kling/Seedance.'
                      message:
                        type: string
                        description: |
                          Optional human-readable status hint emitted on engine-internal
                          retries (e.g. "Product image triggered face filter — retrying
                          without image..."). Safe to log/display; not a failure signal.
                  - type: object
                    required: [done, response]
                    properties:
                      done: { type: boolean, enum: [true] }
                      response:
                        type: object
                        required: [generatedVideos]
                        properties:
                          generatedVideos:
                            type: array
                            items:
                              type: object
                              properties:
                                video:
                                  type: object
                                  properties:
                                    uri: { type: string, format: uri }
                  - type: object
                    required: [done, error]
                    properties:
                      done: { type: boolean, enum: [true] }
                      error:
                        type: object
                        required: [code, message]
                        properties:
                          code:
                            type: string
                            enum: [SAFETY_BLOCK, MODERATION_BLOCKED, GENERATION_FAILED]
                          message: { type: string }
        default: { $ref: '#/components/responses/Error' }

  /proxyFetchVideo:
    post:
      tags: [video]
      summary: Download the finished video
      description: |
        **No credits.** Returns a Firebase Storage signed URL (typical 7-day expiry). For long-term
        retention, copy bytes to your own storage on receipt.

        For Sora extend flows, pass `isExtension: true` so server-side FFmpeg trims correctly.
      operationId: fetchVideo
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operationName, engine]
              properties:
                operationName: { type: string }
                engine: { $ref: '#/components/schemas/Engine' }
                duration: { type: integer, minimum: 4, maximum: 20 }
                isExtension: { type: boolean, default: false }
      responses:
        '200':
          description: Signed URL to the rendered video.
          content:
            application/json:
              schema:
                type: object
                properties:
                  videoUrl: { type: string, format: uri }
                  mimeType: { type: string, example: 'video/mp4' }
                  isWatermarked: { type: boolean }
        default: { $ref: '#/components/responses/Error' }

  /proxyStitchVideos:
    post:
      tags: [post-production]
      summary: Concatenate 1–10 video clips
      description: |
        **No credits.** Per-clip render costs were already paid at
        `proxyStartVideoGeneration`. Optional cross-fade transitions between clips
        (default ~1.0s, clamped 0.3–2.0s); falls back to plain concatenation when a
        clip is too short or when ffmpeg's xfade filter rejects the input format.
      operationId: stitchVideos
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [videoUrls]
              properties:
                videoUrls:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items: { type: string, format: uri }
                  description: |
                    Signed URLs from `proxyFetchVideo` (or any HTTPS-reachable
                    `video/mp4` URL). Order is preserved in the output.
                engines:
                  type: array
                  description: |
                    Optional parallel array of engine names — one per `videoUrls`
                    entry. When provided, Sora-tagged clips get the standard 0.5s
                    start trim that removes the prompt-image flash. Omit if you've
                    already trimmed your sources.
                  items: { $ref: '#/components/schemas/Engine' }
                useCrossfade:
                  type: boolean
                  default: true
                  description: |
                    Set to `false` to skip transitions and use plain concat. Default
                    `true` — the server applies a fadeblack transition where clip
                    durations allow.
                crossfadeDuration:
                  type: number
                  minimum: 0.3
                  maximum: 2.0
                  description: |
                    Override the transition duration in seconds. Ignored when
                    `useCrossfade` is `false`.
      responses:
        '200':
          description: Stitched video URL.
          content:
            application/json:
              schema:
                type: object
                required: [videoUrl, mimeType]
                properties:
                  videoUrl:
                    type: string
                    format: uri
                    description: 'Permanent signed URL of the stitched MP4.'
                  mimeType:
                    type: string
                    example: 'video/mp4'
                  finalUrl:
                    type: string
                    format: uri
                    description: |
                      **Deprecated alias** for `videoUrl`. Returned for backward
                      compatibility with older web-UI clients. Prefer `videoUrl`.
        default: { $ref: '#/components/responses/Error' }

  /proxyApplyTextOverlay:
    post:
      tags: [post-production]
      summary: Burn text overlay onto a video
      description: Cost 1 credit. Multiple overlays in one call supported.
      operationId: applyTextOverlay
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [videoUrl, overlays]
              properties:
                videoUrl: { type: string, format: uri }
                overlays:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/TextOverlay' }
      responses:
        '200':
          description: Video with overlays applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  videoUrl: { type: string, format: uri }
                  mimeType: { type: string }
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateOverlaySuggestions:
    post:
      tags: [post-production]
      summary: AI-suggest text overlays for a script's scenes
      description: Returns hook + body + CTA overlay strings tuned for a target platform.
      operationId: generateOverlaySuggestions
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [script]
              properties:
                script: { $ref: '#/components/schemas/ScriptResult' }
                platform:
                  type: string
                  enum: [tiktok, instagram-reels, youtube-shorts]
      responses:
        '200':
          description: Per-scene overlay suggestions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items:
                      type: object
                      properties:
                        sceneIndex: { type: integer }
                        overlays:
                          type: array
                          items: { $ref: '#/components/schemas/TextOverlay' }
        default: { $ref: '#/components/responses/Error' }

  /proxyListTwins:
    post:
      tags: [twins]
      summary: List your AI Twins
      description: No credits. Returns all twins owned by the calling key's account.
      operationId: listTwins
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
      responses:
        '200':
          description: Twin list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  twins:
                    type: array
                    items: { $ref: '#/components/schemas/Twin' }
        default: { $ref: '#/components/responses/Error' }

  /proxyCreateTwin:
    post:
      tags: [twins]
      summary: Create an AI Twin
      description: |
        Tier-gated AI Twin slot count: creator 1, pro 5, business 20. PAYG users default to creator (1).
        No credit cost — the underlying image generation is billed separately if you call generateTwinImage.
      operationId: createTwin
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TwinCreateRequest' }
      responses:
        '200':
          description: Created twin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Twin' }
        default: { $ref: '#/components/responses/Error' }

  /proxyUpdateTwin:
    post:
      tags: [twins]
      summary: Update an existing AI Twin
      operationId: updateTwin
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [twinId]
              properties:
                twinId: { type: string }
                description: { type: string }
                nicheTopics: { type: array, items: { type: string } }
                contentPillars: { type: array, items: { type: string } }
                voiceDescription: { type: string }
                imageUrl: { type: string, format: uri }
      responses:
        '200':
          description: Updated twin.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Twin' }
        default: { $ref: '#/components/responses/Error' }

  /proxyDeleteTwin:
    post:
      tags: [twins]
      summary: Delete an AI Twin
      operationId: deleteTwin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [twinId]
              properties:
                twinId: { type: string }
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        default: { $ref: '#/components/responses/Error' }

  /proxyAnalyzeVoice:
    post:
      tags: [analysis]
      summary: Analyze a voice sample
      description: |
        Returns a structured voice profile (tone, pace, vocabulary cues) for use in script generation.
        Cost: **3 credits for API callers** (free in the web UI as an onboarding courtesy).
      operationId: analyzeVoice
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [audioUrl]
              properties:
                audioUrl:
                  type: string
                  format: uri
                  description: Public URL or Firebase Storage path to the audio sample (mp3/wav/m4a, ≤2 min).
      responses:
        '200':
          description: Voice profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VoiceProfile' }
        default: { $ref: '#/components/responses/Error' }

  /proxyAnalyzeReferenceVideo:
    post:
      tags: [analysis]
      summary: Analyze a reference video
      description: |
        Returns visual + script + creative analysis of a reference video. Two modes:
        - Standard (3 credits) — Gemini-only analysis.
        - Deep / clone-video (4 credits) — adds keyframe extraction for higher fidelity.
      operationId: analyzeReferenceVideo
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [videoUrl]
              properties:
                videoUrl:
                  type: string
                  description: Public video URL OR a Firebase Storage path under projects/{uid}/reference-videos/...
                storagePath:
                  type: string
                  description: Alternative to videoUrl — direct Firebase Storage path. Use for files >32MB.
                mode:
                  type: string
                  enum: [standard, clone-video]
                  default: standard
      responses:
        '200':
          description: Analysis result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReferenceVideoAnalysis' }
        default: { $ref: '#/components/responses/Error' }

  /proxyGenerateSocialCaptions:
    post:
      tags: [post-production]
      summary: Generate platform-tuned captions for an AI Twin's social post
      description: |
        Returns one caption per requested platform, each tuned to that platform's character
        budget, hashtag conventions, and tone norms (e.g. concise + hashtag-light for OnlyFans,
        narrative + niche-tagged for Fanvue, 20–30 hashtags for Instagram).

        **Mature Mode platforms**: `onlyfans` and `fanvue` require the calling user to have
        paid entitlement AND prior T&C acceptance (web UI only). Calls return:
          - `403 mature_mode_paid_required` for trial users
          - `403 mature_mode_terms_required` for users who have not accepted the Mature Mode T&C

        **Credits**: free; rate-limited via the `text_generation` bucket.
      operationId: generateSocialCaptions
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scenario, platforms]
              properties:
                scenario:
                  type: string
                  description: Free-text description of the post (subject, vibe, context). Drives the caption topic.
                platforms:
                  type: array
                  minItems: 1
                  description: Platforms to generate captions for. Each item produces one caption + hashtags entry in the response. The `onlyfans` and `fanvue` values are gated — see endpoint description.
                  items:
                    type: string
                    enum: [instagram, tiktok, facebook, twitter, linkedin, onlyfans, fanvue]
                twinDescription:
                  type: string
                  description: Free-text Twin description (vibe + persona). Used as caption context so the voice matches the creator.
                contentPillars:
                  type: array
                  items: { type: string }
                  description: AI Twin content pillars (high-level recurring themes).
                nicheTopics:
                  type: array
                  items: { type: string }
                  description: AI Twin niche topics (specific subjects the creator covers).
                preferredTones:
                  type: array
                  items: { type: string }
                  description: AI Twin preferred tones (e.g. "casual & relatable", "playful & flirty").
                tone:
                  type: string
                  description: Single-pass tone override applied to this generation, separate from `preferredTones`.
                voiceDescription:
                  type: string
                  description: AI Twin voice / personality description. When set, the captions are written as if the creator is posting.
                digitalProducts:
                  type: array
                  items: { type: string }
                  description: Products / offers the creator sells. When set, captions may subtly reference expertise but won't force a sales pitch.
                promotedProduct:
                  type: string
                  description: Specific product being promoted in this post. Triggers a sales-angle caption with a platform-appropriate CTA.
      responses:
        '200':
          description: Array of platform-specific captions. One entry per requested platform.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [platform, caption, hashtags]
                  properties:
                    platform:
                      type: string
                      enum: [instagram, tiktok, facebook, twitter, linkedin, onlyfans, fanvue]
                    caption: { type: string, description: Full caption text (excluding hashtags). }
                    hashtags:
                      type: array
                      items: { type: string }
                      description: Platform-appropriate hashtag count (Instagram ~20-30, TikTok 3-5, LinkedIn 3-5, OnlyFans usually empty).
        default: { $ref: '#/components/responses/Error' }

  /saveVideoToLibrary:
    post:
      tags: [library]
      summary: Save a rendered video to the library
      description: |
        No credits. Persists a finished video under your account for later retrieval.

        **Subscription required.** Calls return `403 permission-denied` for non-subscribers
        (free, PAYG, or past_due). The web UI gates this feature the same way.
      operationId: saveVideoToLibrary
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sourceUrl]
              properties:
                sourceUrl:
                  type: string
                  format: uri
                  description: |
                    The temporary signed URL returned by `proxyFetchVideo` (or a `gs://` GCS
                    URL). The server copies the bytes to permanent storage under your account.
                name: { type: string, description: 'Display name for the saved video.' }
                operationName: { type: string }
                engine: { $ref: '#/components/schemas/Engine' }
                modelName: { type: string }
                visualPrompt: { type: string }
                productDescription: { type: string }
                influencerDescription: { type: string }
                hooks:
                  type: array
                  items: { type: string }
                sceneTone: { type: string }
                projectMode: { type: string }
                industry: { type: string }
                duration: { type: number }
      responses:
        '200':
          description: Library entry.
          content:
            application/json:
              schema:
                type: object
                required: [savedUrl, savedAt]
                properties:
                  savedUrl:
                    type: string
                    format: uri
                    description: Permanent signed URL under `saved-videos/{uid}/`.
                  savedAt:
                    type: string
                    format: date-time
                    description: ISO 8601 timestamp of when the entry was created.
        '403': { $ref: '#/components/responses/Error' }
        default: { $ref: '#/components/responses/Error' }

  /proxyGetVideoLibrary:
    post:
      tags: [library]
      summary: List saved videos
      description: |
        Returns paginated saved videos sorted by `savedAt` descending. To fetch the next
        page, pass the previous response's `nextPageCursor` as the `startAfterId` parameter.
      operationId: getVideoLibrary
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                startAfterId:
                  type: string
                  description: |
                    Cursor for pagination — pass the `nextPageCursor` from the previous
                    response. Opaque Firestore document ID; do not parse.
                pageSize:
                  type: integer
                  minimum: 1
                  maximum: 50
                  default: 20
                  description: 'Server caps at 50 even if a larger value is supplied.'
      responses:
        '200':
          description: Paginated library.
          content:
            application/json:
              schema:
                type: object
                required: [videos, nextPageCursor, hasMore]
                properties:
                  videos:
                    type: array
                    items: { $ref: '#/components/schemas/SavedVideo' }
                  nextPageCursor:
                    type: [string, 'null']
                    description: 'Pass to the next call as `startAfterId`. `null` when no more pages.'
                  hasMore: { type: boolean }
        default: { $ref: '#/components/responses/Error' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ugc_live_<32 hex chars>
      description: |
        API key issued from the web UI under **Profile → API Keys**. Format: `ugc_live_` followed
        by 32 hex characters (41 chars total). Sent as `Authorization: Bearer ugc_live_...`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        UUID or any URL-safe string ≤256 chars. Dedupes within a 24-hour window — the same key
        sent twice returns the cached first response. **Strongly recommended for mutating /
        credit-deducting calls** to prevent network-retry double-charges. Reusing a key with a
        different request body returns `422 idempotency-conflict`.
      schema:
        type: string
        maxLength: 256

  headers:
    X-Request-ID:
      description: Unique ID for this request — quote in support tickets.
      schema: { type: string, format: uuid }
    X-Credits-Remaining:
      description: Credits left in the current monthly window (excludes bonus credits below 0).
      schema: { type: integer, minimum: 0 }
    X-RateLimit-Limit:
      description: Max requests in the current minute window for this actionType.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current minute window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix seconds when the current rate-limit window resets.
      schema: { type: integer }
    X-Idempotent-Replayed:
      description: Set to `true` when the response was served from the idempotency cache.
      schema: { type: boolean }
    Retry-After:
      description: Seconds to wait before retrying (set on 429 responses).
      schema: { type: integer, minimum: 0 }

  responses:
    Error:
      description: Error response.
      headers:
        X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    InsufficientCredits:
      description: Credit pool exhausted (monthly + bonus packs).
      headers:
        X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    RateLimited:
      description: Per-key rate limit or concurrency cap exceeded.
      headers:
        X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    IdempotencyConflict:
      description: Idempotency-Key reused with a different request body.
      headers:
        X-Request-ID: { $ref: '#/components/headers/X-Request-ID' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

  schemas:
    Engine:
      type: string
      enum: [sora, veo, kling, seedance]
      description: Video generation engine.

    Quality:
      type: string
      enum: [standard, hq]

    ProjectMode:
      type: string
      enum: [product-ad, creator, creator-intimate, ugc-creator, podcast-style, live-broadcast, influencer-noproduct, vlog, clone-video, own-script]
      description: |
        Content creation mode.

        - `creator` is the current canonical mode for creator-led / lifestyle / vlog / haul content. Replaces the deprecated `ugc-creator`, `influencer-noproduct`, and `vlog` values (still accepted for backwards compatibility — the backend `normalizeProjectMode` helper maps them to `creator` on read).
        - `creator-intimate` ("Mature Mode") generates suggestive (not explicit) photo sets and video clips for OnlyFans / Fanvue / Instagram subscriber-platform creators. Requires:
          - **Paid entitlement** (active subscription OR `bonusCredits > 0`) — calls from trial users return `403 mature_mode_paid_required`.
          - **T&C acceptance** — calls from users without `hasAcceptedMatureModeTerms: true` return `403 mature_mode_terms_required`. The T&C acceptance flow is web-UI only at present; API-only users must accept once via the web app before using this mode.
          - **Engine lock** — `engine` must be `kling`. Other engines return `400 mode_engine_not_allowed` from `/proxyStartVideoGeneration`.
        - `live-broadcast` renders the subject as a fan/spectator caught on the broadcast cam — booth play-by-play narrates them in voiceover, ESPN-style on-screen graphics (score bug, lower-third chyron, ticker) are composited onto every scene image.

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message, request_id]
          properties:
            type:
              type: string
              enum: [validation, authentication, permission, idempotency, rate_limit, resource, concurrency, internal]
              description: |
                Coarse error category for client-side branching. The pair `(type, code)` is
                stable across versions; the `code` is the more specific identifier.
            code:
              type: string
              description: |
                Machine-readable code: invalid-argument, unauthenticated, permission-denied,
                not-found, idempotency-in-progress, idempotency-conflict, resource-exhausted,
                concurrency-exceeded, insufficient-credits, already-exists, failed-precondition,
                internal.

                Mode-specific codes returned when `projectMode === 'creator-intimate'`:
                - `mature_mode_paid_required` (403, type=permission): user is on a trial plan
                  with no credit pack. Mature Mode requires paid entitlement.
                - `mature_mode_terms_required` (403, type=permission): user has not accepted
                  the Mature Mode T&C. Acceptance is web-UI only at present — direct API
                  callers must complete the gate once via the web app before using this mode.
                - `mode_engine_not_allowed` (400, type=validation): returned by
                  `/proxyStartVideoGeneration` when an engine other than `kling` is passed
                  with `projectMode === 'creator-intimate'`. The error details include the
                  allowed engines list.
            message:
              type: string
            request_id:
              type: string
              format: uuid
            retryAfter:
              type: integer
              description: Set on 429 responses — seconds to wait before retry.
            field:
              type: string
              description: Set on validation errors when a specific field is at fault.

    TrendingProduct:
      type: object
      properties:
        name: { type: string }
        brand: { type: string }
        priceRange: { type: string }
        adAngle: { type: string }
        idealInfluencer:
          type: object
          properties:
            ageRange: { type: string }
            gender: { type: string }
            ethnicity: { type: string }
            voicePersona: { type: string }
        sourceUrl: { type: string, format: uri }

    MarketAnalysis:
      type: object
      properties:
        industry: { type: string }
        products:
          type: array
          items: { $ref: '#/components/schemas/TrendingProduct' }
        competitorAngles:
          type: array
          items: { type: string }
        audienceSegments:
          type: array
          items:
            type: object
            properties:
              label: { type: string }
              description: { type: string }
        creativeRecommendations:
          type: array
          items: { type: string }

    ScriptGenerateRequest:
      type: object
      required: [productDescription]
      properties:
        productDescription: { type: string, minLength: 1, maxLength: 4000 }
        twinDescription: { type: string }
        twinId: { type: string, description: Reuse a saved AI Twin instead of describing a creator inline. }
        voiceProfile: { $ref: '#/components/schemas/VoiceProfile' }
        tone: { type: string, example: 'humorous' }
        platform:
          type: string
          enum: [tiktok, instagram-reels, youtube-shorts]
        projectMode: { $ref: '#/components/schemas/ProjectMode' }
        isFaceless: { type: boolean, default: false }
        targetDurationSec: { type: integer, minimum: 8, maximum: 90 }
        twinIntendedForIntimateMode:
          type: boolean
          description: |
            When true, the referenced AI Twin was built for Mature Mode (creator-intimate).
            Carries the intimate creator voice (confessional / sultry / teasing register,
            subscriber-platform CTAs) into the script even when `projectMode` itself is not
            `creator-intimate`. Visuals continue to follow `projectMode`; the override
            affects voice + CTA register only. Optional — defaults to the Twin's saved flag.

    ScriptResult:
      type: object
      properties:
        hooks:
          type: array
          items: { type: string }
        scenes:
          type: array
          items: { $ref: '#/components/schemas/Scene' }
        ctas:
          type: array
          items: { type: string }
        platformVariations:
          type: object
          additionalProperties:
            type: object
            properties:
              hook: { type: string }
              cta: { type: string }
              caption: { type: string }
        broadcastGraphics:
          allOf:
            - $ref: '#/components/schemas/BroadcastGraphics'
          description: |
            Populated only when the request used `projectMode === 'live-broadcast'`. Same
            matchup is reused across all scenes to keep score-bug / chyron / ticker consistent.
        rawScript: { type: string }
      additionalProperties: true

    Scene:
      type: object
      properties:
        index: { type: integer, minimum: 0 }
        visualPrompt: { type: string }
        scriptText: { type: string }
        soundCues: { type: array, items: { type: string } }
        durationSec: { type: number, minimum: 1 }

    BroadcastGraphics:
      type: object
      description: |
        ESPN-style on-screen graphics package for `live-broadcast` mode. Generated as part of
        the script response and passed back into scene-image generation so the score bug,
        lower-third chyron, and bottom ticker stay consistent across every scene in a project.
        Team names, abbreviations, scores, and venue MUST stay identical for every scene —
        narrative momentum lives in the voiceover, not in score progression.
      required: [homeTeam, homeAbbrev, awayTeam, awayAbbrev, homeScore, awayScore, gameState, venueName]
      properties:
        homeTeam: { type: string, example: 'Boston Crusaders' }
        homeAbbrev: { type: string, minLength: 2, maxLength: 4, example: 'BOS' }
        awayTeam: { type: string, example: 'Pacific Tide' }
        awayAbbrev: { type: string, minLength: 2, maxLength: 4, example: 'PAC' }
        homeScore:
          type: string
          example: '24'
          description: String so it can carry "0", "—", fractional baseball state, etc.
        awayScore: { type: string, example: '17' }
        gameState:
          type: string
          example: 'Q3 11:42'
          description: |
            Sport-aware clock+period string. Examples by sport — Basketball: "Q3 11:42",
            Football: "2nd & 7 — Q3 4:12", Soccer: "67'", Baseball: "Bottom 7th — 2-2, 1 out",
            Hockey: "P2 8:47", MMA/Boxing: "Round 3 2:14", Tennis: "Set 2, 5-4, 30-15".
        venueName: { type: string, example: 'Riverside Arena' }
        networkName:
          type: string
          example: 'SBN'
          description: Fake network ID for the score-bug logo block.
        tickerLines:
          type: array
          items: { type: string }
          example: ['BOS 24 — CHI 17 — FINAL', 'LAK vs SEA — 7:30 ET']
          description: 3–5 scrolling lines for the bottom ticker.

    StartVideoRequest:
      type: object
      required: [visualPrompt, engine, modelName]
      properties:
        visualPrompt: { type: string, minLength: 1 }
        engine: { $ref: '#/components/schemas/Engine' }
        modelName:
          type: string
          description: |
            Engine-specific model identifier. Examples: 'sora-2', 'sora-2-pro',
            'veo-3.1-generate-preview', 'fal-ai/kling-video/v3/standard/image-to-video',
            'fal-ai/kling-video/v3/4k/image-to-video',
            'bytedance/seedance-2.0/image-to-video'.
        sceneImage:
          type: object
          properties:
            data: { type: string, description: 'Base64-encoded image (no data: prefix).' }
            mimeType: { type: string, example: 'image/png' }
        duration:
          type: integer
          minimum: 4
          maximum: 20
          description: Render duration in seconds (engine-clamped).
        editVideoId:
          type: string
          description: For Sora extend flows — the source video to extend.
        isFaceless: { type: boolean, default: false }
        projectMode: { $ref: '#/components/schemas/ProjectMode' }
        productDescription: { type: string }
        influencerDescription: { type: string }
        twinContext:
          type: object
          description: |
            Optional Twin-derived context that the engine consumes for cross-scene
            consistency. Most engines read `masterIdentityPrompt` (Sora, Kling, Seedance);
            Veo intentionally ignores it because its safety filter trips on detailed
            physical descriptions paired with an I2V reference image — for Veo the
            identity flows through the `sceneImage` start frame instead.
          properties:
            masterIdentityPrompt:
              type: string
              maxLength: 2000
              description: |
                Canonical "actor playing the role" description (face DNA, body
                proportions, signature visual markers — no clothing or scene context).
                Reused verbatim across every scene to keep the same character
                reappearing. Backticks and `[IDENTITY ...]` / `[/IDENTITY]` delimiters
                are stripped server-side. Kling further truncates this to 800 characters
                due to its 2500-char total prompt cap.
            ethnicity: { type: string }
            gender: { type: string }
            ageRange: { type: string }

    Twin:
      type: object
      properties:
        id: { type: string }
        description: { type: string }
        nicheTopics: { type: array, items: { type: string } }
        contentPillars: { type: array, items: { type: string } }
        voiceDescription: { type: string }
        voiceProfile: { $ref: '#/components/schemas/VoiceProfile' }
        imageUrl:
          type: [string, 'null']
          format: uri
        ethnicity: { type: string }
        gender: { type: string }
        ageRange: { type: string }
        masterIdentityPrompt:
          type: string
          maxLength: 2000
          description: |
            Cached canonical actor description (face/body identity only — no clothing or
            setting). Reused verbatim in scene image generation and in Sora/Kling/Seedance
            video character blocks for cross-scene consistency.
        identityVersion:
          type: integer
          minimum: 1
          readOnly: true
          description: |
            Server-managed counter, bumped atomically (via Firestore `FieldValue.increment`)
            on every non-empty write to `masterIdentityPrompt`. Lets snapshots pin to a
            specific identity revision. Clients cannot write this field directly — any
            client-supplied value is dropped.
        createdAt: { type: number, description: Timestamp in milliseconds. }

    TwinCreateRequest:
      type: object
      required: [description]
      properties:
        description: { type: string, minLength: 1, maxLength: 2000 }
        nicheTopics: { type: array, items: { type: string } }
        contentPillars: { type: array, items: { type: string } }
        voiceDescription: { type: string }
        ethnicity: { type: string }
        gender: { type: string }
        ageRange: { type: string }
        imageUrl: { type: string, format: uri }
        masterIdentityPrompt:
          type: string
          maxLength: 2000
          description: |
            Optional canonical actor description. When present, the twin is created with
            `identityVersion: 1`. Server strips backticks and `[IDENTITY]` delimiters.

    VoiceProfile:
      type: object
      properties:
        tone: { type: string }
        pace: { type: string }
        vocabulary: { type: array, items: { type: string } }
        signaturePhrases: { type: array, items: { type: string } }
        notes: { type: string }
      additionalProperties: true

    TextOverlay:
      type: object
      required: [text, startSec, endSec]
      properties:
        text: { type: string, minLength: 1, maxLength: 200 }
        startSec: { type: number, minimum: 0 }
        endSec: { type: number, minimum: 0 }
        position:
          type: string
          enum: [top, center, bottom]
          default: bottom
        style:
          type: object
          properties:
            fontFamily: { type: string }
            fontSize: { type: integer, minimum: 8, maximum: 200 }
            color: { type: string, example: '#ffffff' }
            backgroundColor:
              type: [string, 'null']

    SavedVideo:
      type: object
      required: [id, savedUrl, savedAt]
      properties:
        id: { type: string, description: 'Pass as `startAfterId` to paginate after this entry.' }
        savedUrl: { type: string, format: uri, description: 'Permanent signed URL.' }
        savedAt: { type: string, format: date-time, description: 'ISO 8601 timestamp.' }
        name: { type: [string, 'null'] }
        visualPrompt: { type: [string, 'null'] }
        productDescription: { type: [string, 'null'] }
        sceneTone: { type: [string, 'null'] }
        engine:
          oneOf:
            - $ref: '#/components/schemas/Engine'
            - type: 'null'
        modelName: { type: [string, 'null'] }
        projectMode: { type: [string, 'null'] }
        industry: { type: [string, 'null'] }
        duration: { type: [number, 'null'] }
        hasEmbedding: { type: boolean, default: false }

    ReferenceVideoAnalysis:
      type: object
      properties:
        summary: { type: string }
        scenes:
          type: array
          items:
            type: object
            properties:
              startSec: { type: number }
              endSec: { type: number }
              visualDescription: { type: string }
              script: { type: string }
        creativeStructure:
          type: object
          properties:
            hook: { type: string }
            body: { type: string }
            cta: { type: string }
        recommendedHookVariants:
          type: array
          items: { type: string }
      additionalProperties: true
