openapi: 3.0.3
info:
  title: Promoat Partner API
  version: "1.1.0"
  description: |
    API for Promoat B2B partners: mint an API key, buy credits, then generate AI presenters
    (characters), clone voices, synthesize speech, generate first-frame images, produce videos
    (single-scene talking head, or scenes where your character acts the script out), and edit
    finished videos scene by scene.

    **Auth**: every `/api/partner/v1/*` endpoint takes `Authorization: Bearer pmat_<key>`.
    Click **Authorize** above and paste your key once; it's attached to every request you
    try below.

    **Credits**: everything is priced in prepaid credits (1 credit = $0.01). Prices follow the
    price table named by `price_table_version` (currently `2026-09-30`).
    Async endpoints (`/videos` and the video edits) reserve the quote before the job starts; the
    quote is a hard ceiling: a job is charged `min(actual, reserved)` and a failed job is refunded
    once. Instant endpoints (`/characters` (free), `/characters/{id}/regenerate`, `/voices`, `/tts`,
    `/images/first-frame`) charge once and return `credits_charged`; a failure is refunded.
    `credits_consumed` on a job is `null` while `queued`/`running`, `actual_credits` (falling
    back to `estimated_credits`) once `completed`, and `0` on `failed` unless a partial
    `actual_credits` was recorded. A job that makes no progress for 75 minutes is failed with
    `STALE_TIMEOUT` and refunded.

    **Errors**: since 1.1.1 every error body on every endpoint has `error`, `error_code` (the
    machine code, UPPER_SNAKE; branch on it) and `message`, plus `field` when one field is at
    fault. Failures that existed before 1.1.0 keep their old `error` string (e.g. `Unauthorized`
    with `error_code: UNAUTHORIZED`, `Not found` with `NOT_FOUND`) and HTTP status; newer failures
    use the code itself as `error`. Some bodies also carry a lowercase `code`; don't branch on it.

    **Warnings**: a 200 may carry `warnings: [{field, code, message}]` for fields that were
    accepted but changed or ignored (`RESOLUTION_MAPPED`, `FIELD_IGNORED`,
    `SCENE_PHOTOS_TRUNCATED`, `LOW_SAMPLE_AUDIO`). Job-time warnings (`SCENE_PHOTO_DROPPED`) are
    listed on the job's `GET /usage/jobs` row as `metadata.warnings`.

    **Idempotency**: send an `Idempotency-Key` header (any 1-255 visible ASCII characters) or a
    `client_request_id` (uuid) in the body. If both are sent they must be the same key. A replay
    of the same key with the same body returns `200` with `duplicate: true` and charges nothing
    again; the same key with a different body is `409 IDEMPOTENCY_KEY_REUSED`. On `/videos` one key
    covers every video type (a talking-head body and then a scenes body under one key is a 409),
    and the body is validated and priced again before the duplicate lookup, so a retry can get
    `409 QUOTE_MISMATCH` (resend with the new `expected_credits` under the same key) or a 404/422 if
    something it referenced is gone. Edits answer the duplicate first. The key is optional on `/videos` and required on the edit endpoints
    (unless `dry_run: true`).

    **Changes in 1.1.0**: new video engines and prices (e.g. a 15 s talking head at 768P from your
    own audio is 125 credits); default resolution 768P (was 480p); `/tts` uses the v4 voice model;
    captions remain a talking-head add-on; `error_code` on errors; idempotency and
    `expected_credits`; new endpoints `/videos/scene-plan`, `/videos/{workflowId}/scenes`,
    `/videos/{workflowId}/fix-scene`, `/videos/{workflowId}/edit-script`,
    `/videos/{workflowId}/fix-sound`, `/voices/presets`, `/voices/{id}/reclone`,
    `/characters/{id}/regenerate`. Every 1.0.0 request still validates.
  contact:
    name: Promoat Partner API
servers:
  - url: /
    description: This deployment (matches whatever origin this page is served from)
  - url: http://localhost:3000
    description: Local dev (npm run dev)
security:
  - BearerAuth: []
tags:
  - name: Characters
    description: AI presenter identities. Clone a real person from selfies, or invent one from a description.
  - name: Voices
    description: Your voices, two kinds. Clone a real voice from audio samples, or pick a studio voice. Every voice_id must be one you created.
  - name: TTS
    description: Text-to-speech with one of your own voices.
  - name: Images
    description: Still-frame generation for a character.
  - name: Videos
    description: Async video generation. `talking_head` (single-scene presenter) or `scenes` (your character acts the script out).
  - name: Edits
    description: Edit a finished video. Every edit makes a NEW video; the source is untouched and stays the undo.
  - name: Jobs
    description: Poll async job status.
  - name: Assets
    description: The durable ledger of every output you've generated (sheets, frames, audio, video).
  - name: Usage
    description: Credit balance and job history.
  - name: Billing
    description: Buy credits through Stripe Checkout.
paths:
  /api/partner/v1/characters:
    post:
      tags: [Characters]
      summary: Create a character (clone from selfies, or invent from a description)
      description: |
        Free (0 credits, as in the app; only `/characters/{id}/regenerate` costs 4). Image URLs are
        fetched only after authentication, through a URL guard (public
        https hosts only, size-capped). An image that fails the guard is dropped with a
        `FIELD_IGNORED` warning; if no selfie is usable the call is a 422 and nothing is charged.
        Stored image URLs are our own re-hosted copies, never your URL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 120 }
                selfie_image_urls:
                  type: array
                  items: { type: string, format: uri }
                  minItems: 1
                  maxItems: 3
                  description: Photo mode. 1-3 https selfie URLs. Reproduces the EXACT person.
                edit_instructions:
                  type: string
                  maxLength: 500
                  description: Photo mode only. A tweak applied while keeping the same identity (e.g. "younger", "curly hair").
                description:
                  type: string
                  maxLength: 2000
                  description: Description mode. Invents a presenter from this free text.
                reference_image_urls:
                  type: array
                  items: { type: string, format: uri }
                  maxItems: 3
                  description: Description mode only. Loose style inspiration, never an identity to copy.
                recast_attempt:
                  type: integer
                  minimum: 1
                  description: Description mode only. N>0 forces a noticeably different invented person.
              example:
                name: "Ava"
                description: "a friendly, upbeat presenter in her early 30s"
      responses:
        "200":
          description: Character created
          content:
            application/json:
              schema:
                type: object
                properties:
                  character_id: { type: string, format: uuid }
                  name: { type: string }
                  mode: { $ref: "#/components/schemas/CharacterMode" }
                  sheet_image_url: { type: string, format: uri }
                  character_sheet_text: { type: string, nullable: true }
                  asset_id: { type: string, nullable: true }
                  presenter_gender: { $ref: "#/components/schemas/PresenterGender" }
                  head_covering: { $ref: "#/components/schemas/HeadCovering" }
                  credits_charged: { type: integer, example: 0 }
                  warnings: { type: array, items: { $ref: "#/components/schemas/Warning" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: Could not load any of the provided selfie images (not charged)
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/TryAgain" }
    get:
      tags: [Characters]
      summary: List your characters
      responses:
        "200":
          description: Characters, newest first (max 100)
          content:
            application/json:
              schema:
                type: object
                properties:
                  characters:
                    type: array
                    items:
                      type: object
                      properties:
                        character_id: { type: string, format: uuid }
                        name: { type: string }
                        mode: { $ref: "#/components/schemas/CharacterMode" }
                        sheet_image_url: { type: string, format: uri }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/characters/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Characters]
      summary: Get a character
      responses:
        "200":
          description: Character
          content:
            application/json:
              schema:
                type: object
                properties:
                  character_id: { type: string, format: uuid }
                  name: { type: string }
                  mode: { $ref: "#/components/schemas/CharacterMode" }
                  sheet_image_url: { type: string, format: uri }
                  character_sheet_text: { type: string, nullable: true }
                  created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Characters]
      summary: Delete a character
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { type: object, properties: { deleted: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/characters/{id}/regenerate:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Characters]
      summary: Re-render a character's sheet with an optional tweak (same person)
      description: |
        New in 1.1.0. 4 credits. The sheet is updated in place and a new `character_sheet` asset
        is written. A photos-mode character is re-rendered from its selfies, any other from its
        current sheet. Unreadable references are a 422 and nothing is charged.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                edit_instructions: { type: string, maxLength: 500, example: "shorter hair, keep everything else" }
      responses:
        "200":
          description: Regenerated
          content:
            application/json:
              schema:
                type: object
                properties:
                  character_id: { type: string, format: uuid }
                  sheet_image_url: { type: string, format: uri }
                  asset_id: { type: string, nullable: true }
                  credits_charged: { type: integer, example: 4 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: Could not load the reference photos (not charged)
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/partner/v1/voices:
    post:
      tags: [Voices]
      summary: Create a voice (clone from samples, or pick a studio voice)
      description: |
        Provide exactly one source:

        **`sample_urls`** clones a real voice from your audio samples. Your **first clone is
        free** (once per client, ever: deleting and re-creating a voice does not make the next one
        free); later clones cost 30 credits. Samples are fetched through a URL guard; unusable
        samples are skipped (`sample_errors`). Less than 25 s of usable speech adds a
        `LOW_SAMPLE_AUDIO` warning; samples with no speech are `422 NO_SPEECH` (refunded). Capped
        per client (`PARTNER_MAX_VOICES_PER_CLIENT`, default 3): delete an old cloned voice to
        free a slot. The new voice id starts with `pv_`.

        **`language`** / **`studio_voice`** picks a studio voice (see `GET /voices/presets`):
        named by `studio_voice`, or picked for you (the same name always gets the same voice).
        **Free**, with its own cap (default 10). The id starts with `sv_`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 80 }
                sample_urls:
                  type: array
                  items: { type: string, format: uri }
                  minItems: 1
                  description: Clone mode. Public https audio URLs with 5 s to 5 min of clear speech each. 1-25; only the first 25 are used, non-https URLs are dropped; 400 when none is usable. Don't send `language` with sample_urls (use preview_language).
                preview_language:
                  type: string
                  description: Clone mode. Language of the preview line (ISO code or name). Default English.
                language:
                  type: string
                  description: 'Library mode. A language name or ISO code ("english", "arabic", "es", …). Studio voices are multilingual.'
                gender:
                  type: string
                  enum: [female, male]
                  default: female
                  description: Library mode only.
                studio_voice:
                  type: string
                  description: Library mode only. One of `GET /voices/presets` (case-insensitive). 400 `INVALID_STUDIO_VOICE` otherwise.
            examples:
              clone:
                summary: Clone from audio samples (first clone free, then 30 credits)
                value:
                  name: "Ava voice"
                  sample_urls: ["https://your-cdn.com/voice-sample.mp3"]
              library:
                summary: Pick a studio voice (free)
                value:
                  name: "Spanish narrator"
                  language: "spanish"
                  gender: "female"
      responses:
        "200":
          description: Voice created
          content:
            application/json:
              schema:
                type: object
                properties:
                  voice_id: { type: string, description: "Opaque voice id (`pv_…` clone, `sv_…` studio voice; older ids keep working)." }
                  name: { type: string }
                  kind: { type: string, enum: [cloned, library] }
                  language: { type: string, nullable: true, description: "Library voices only." }
                  studio_voice: { type: string, description: "Library voices only." }
                  gender: { type: string, enum: [female, male], description: "Library voices only." }
                  preview_url: { type: string, format: uri, description: "Clones only. A short sample in the new voice." }
                  credits_charged: { type: integer, description: "Clone: 0 for your first clone, 30 after. Library: 0." }
                  warnings: { type: array, items: { $ref: "#/components/schemas/Warning" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "409":
          description: Voice cap reached (cloned and library voices have separate caps)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "VOICE_LIMIT_REACHED" }
                  max_voices: { type: integer }
        "422":
          description: No speech in the samples (`NO_SPEECH`, refunded)
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
        "500": { $ref: "#/components/responses/ServerError" }
        "502":
          description: Cloning is temporarily unavailable (`CLONE_UNAVAILABLE`, refunded)
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
    get:
      tags: [Voices]
      summary: List your voices
      responses:
        "200":
          description: Voices, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  voices:
                    type: array
                    items:
                      type: object
                      properties:
                        voice_id: { type: string }
                        name: { type: string }
                        kind: { type: string, enum: [cloned, library] }
                        language: { type: string, nullable: true }
                        created_at: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/voices/presets:
    get:
      tags: [Voices]
      summary: List the studio voices
      description: New in 1.1.0. Free. Pass a `studio_voice` name to `POST /voices` to pick one.
      responses:
        "200":
          description: Studio voices
          content:
            application/json:
              schema:
                type: object
                properties:
                  voices:
                    type: array
                    items:
                      type: object
                      properties:
                        studio_voice: { type: string, example: "Sarah" }
                        gender: { type: string, enum: [female, male] }
                        languages: { type: string, enum: [multilingual] }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/voices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: The voice_id returned when the voice was created
    delete:
      tags: [Voices]
      summary: Delete a voice (for cloned voices this frees the clone slot)
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { type: object, properties: { deleted: { type: boolean } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/voices/{id}/reclone:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: The cloned voice to make again
    post:
      tags: [Voices]
      summary: Clone a voice again from new samples (gets a NEW voice_id)
      description: |
        New in 1.1.0. The old voice is removed and a new `voice_id` is returned; update your
        references. 30 credits when the old voice still speaks, 0 when it has expired. Studio
        voices are not clones: 400 `NOT_A_CLONED_VOICE`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sample_urls]
              properties:
                sample_urls:
                  type: array
                  items: { type: string, format: uri }
                  minItems: 1
                  description: 1-25 public https audio URLs; only the first 25 are used; 400 when none is usable.
                preview_language: { type: string }
      responses:
        "200":
          description: Re-cloned
          content:
            application/json:
              schema:
                type: object
                properties:
                  voice_id: { type: string }
                  previous_voice_id: { type: string }
                  name: { type: string }
                  kind: { type: string, enum: [cloned] }
                  preview_url: { type: string, format: uri }
                  credits_charged: { type: integer }
                  warnings: { type: array, items: { $ref: "#/components/schemas/Warning" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: No speech in the samples (`NO_SPEECH`, refunded)
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
        "500": { $ref: "#/components/responses/ServerError" }
        "502":
          description: Cloning is temporarily unavailable (`CLONE_UNAVAILABLE`, refunded)
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }

  /api/partner/v1/tts:
    post:
      tags: [TTS]
      summary: Synthesize speech with one of your own voices
      description: |
        voice_id must belong to this client (404 otherwise). Speech uses the v4 voice model (since
        1.1.0); there is no model choice. Price: max(10, ceil(characters / 1000) × 10) credits.
        A voice that can no longer speak is `409 VOICE_UNAVAILABLE` (nothing charged).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [voice_id, text]
              properties:
                voice_id: { type: string }
                text: { type: string, maxLength: 5000 }
                language_code: { type: string, example: "en" }
                language: { type: string, description: "ISO code or language name; same effect as language_code." }
                voice_settings: { $ref: "#/components/schemas/VoiceSettings" }
      responses:
        "200":
          description: Synthesized
          content:
            application/json:
              schema:
                type: object
                properties:
                  audio_url: { type: string, format: uri }
                  duration_sec: { type: number }
                  asset_id: { type: string, nullable: true }
                  characters: { type: integer }
                  credits_charged: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The voice can no longer speak
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "VOICE_UNAVAILABLE" }
                  error_code: { type: string, example: "voice_not_found" }
                  voice_state: { type: string, enum: [expired, missing] }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/partner/v1/images/first-frame:
    post:
      tags: [Images]
      summary: Generate a 9:16 still of a character (a talking-head frame or a realistic photo for scenes)
      description: |
        Instant (synchronous). 4 credits. The output is always 9:16. Use the returned `frame_id`
        as `first_frame_asset_id` on `POST /videos`: a `talking_head` frame for a talking-head
        video, a `moving_scene` frame (the realistic photo) for a scenes video. One of
        `scene_description`, `scene_prompt` or `script` is required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [character_id]
              properties:
                character_id: { type: string, format: uuid }
                scene_description: { type: string, maxLength: 2000, description: "The \"video idea\" / what this image should show." }
                kind: { $ref: "#/components/schemas/VideoType" }
                scene_prompt: { type: string, maxLength: 400, description: "The setting, in a few words." }
                scene_photos:
                  type: array
                  maxItems: 12
                  items: { $ref: "#/components/schemas/ScenePhoto" }
                  description: "Real stills (setting / product / prop) for the frame; the first 3 unique URLs are used (SCENE_PHOTOS_TRUNCATED), more than 12 items is 400 INVALID_SCENE_PHOTOS. Not used for `kind: moving_scene` (FIELD_IGNORED)."
                script: { type: string, maxLength: 4000, description: "Context for the setting only; never written into the image." }
                language: { type: string }
      responses:
        "200":
          description: Image generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  image_url: { type: string, format: uri }
                  asset_id: { type: string, nullable: true }
                  frame_id: { type: string, nullable: true, description: "Same as asset_id." }
                  kind: { $ref: "#/components/schemas/VideoType" }
                  credits_charged: { type: integer, example: 4 }
                  warnings: { type: array, items: { $ref: "#/components/schemas/Warning" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500":
          description: "`{\"error\":\"First-frame generation failed\", \"error_code\", \"message\"}` (refunded)"
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }

  /api/partner/v1/videos:
    post:
      tags: [Videos]
      summary: Queue a video generation job (talking_head or scenes)
      description: |
        Async: poll `GET /api/partner/v1/jobs/{workflow_id}/status` until `completed`/`failed`.
        Price a request first with `POST /videos/estimate` (same body, free); the number it
        returns is exactly what this call reserves.

        **`mode: "talking_head"`**: one continuous shot of the character presenting to camera,
        lip-synced to the audio. Audio is exactly one of `{voice_id, script}` or `{audio_url}`.
        Needs `video_idea` unless you supply a frame (`first_frame_url` or `first_frame_asset_id`).
        Optional dynamic captions (`use_dynamic_captions`, an add-on).

        **`mode: "scenes"`** (alias `moving_scene`): your character acts the script out across
        several clips, one vertical video. Voice is `{voice_id, script}` or `{audio_url}`, or no
        voice at all with `voice_mode: "none"` + `target_seconds`. `video_mode: "text"` makes it
        from the words alone (no photos, cheaper).

        Audio: under 1 s is rejected (`AUDIO_TOO_SHORT`); 1-5 s is billed as 5 s; voiced audio is
        at most 59.5 s (`AUDIO_TOO_LONG`). Scripts are at most 4,000 characters. A script job
        reserves at an estimated length (with headroom) plus speech synthesis, then charges the
        measured price, never more than the reserve.

        Since 1.1.0 these fields are accepted and ignored (warning `FIELD_IGNORED`): `fast`,
        `narration_mode`, `lipsync`, `audio_duration`, `first_frame_title`, and
        (since 1.1.1, which says so instead of dropping them silently) `topic`, `audience`. A `first_frame_url`
        or `audio_url` that is not an https URL is ignored the same way.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VideoCreateRequest" }
            examples:
              talking_head:
                summary: Talking head from a script
                value:
                  mode: "talking_head"
                  character_id: "00000000-0000-0000-0000-000000000000"
                  voice_id: "your_voice_id"
                  script: "Hi! Quick update on what's new this week."
                  video_idea: "standing in a bright modern kitchen, smiling at the camera"
                  resolution: "768P"
              talking_head_audio:
                summary: Talking head from your own recording, with a confirmed price
                value:
                  mode: "talking_head"
                  character_id: "00000000-0000-0000-0000-000000000000"
                  audio_url: "https://cdn.example.com/take.mp3"
                  video_idea: "Morning coffee review at a sunny kitchen counter"
                  expected_credits: 125
              scenes:
                summary: Scenes video with two scene photos
                value:
                  mode: "scenes"
                  character_id: "00000000-0000-0000-0000-000000000000"
                  voice_id: "your_voice_id"
                  script: "Ever wondered how we roast our beans? Come on, I'll show you. This is where every batch starts."
                  scene_prompt: "A small coffee roastery at opening time"
                  scene_photos:
                    - { url: "https://cdn.example.com/shop.jpg", role: "setting" }
                    - { url: "https://cdn.example.com/bag.png", role: "product" }
              silent:
                summary: Silent scenes video, 15 s
                value:
                  mode: "scenes"
                  character_id: "00000000-0000-0000-0000-000000000000"
                  voice_mode: "none"
                  target_seconds: 15
                  video_idea: "a barista pouring latte art in slow motion"
      responses:
        "200":
          description: Queued (or a duplicate of an earlier request with the same key)
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow_id: { type: string, example: "partner_h3t_3b0f9c1e0d2a4b5c6d7e8f9a0b1c2d3e" }
                  status: { type: string, example: "queued" }
                  credits_reserved: { type: integer }
                  credits_quoted: { type: integer }
                  job_type: { $ref: "#/components/schemas/JobType" }
                  resolution: { $ref: "#/components/schemas/Resolution" }
                  price_table_version: { type: string, example: "2026-09-30" }
                  warnings: { type: array, items: { $ref: "#/components/schemas/Warning" } }
                  duplicate: { type: boolean, description: "true when this answers a replay of an earlier request (same Idempotency-Key and body); only workflow_id, status and credits_reserved are returned then." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422":
          description: audio_url could not be read as audio
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
        "429": { $ref: "#/components/responses/TooManyActiveJobs" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/TryAgain" }

  /api/partner/v1/videos/estimate:
    post:
      tags: [Videos]
      summary: Price a video request without queueing it
      description: |
        Same body as `POST /videos`. Returns the credits that call would reserve, your live
        balance, and the line items. Free, no side effects: nothing is charged, held, or queued.
        The referenced character/voice don't need to exist yet; the only network work is the
        `audio_url` duration probe. `character_id` is not needed, and ids (e.g.
        `first_frame_asset_id`) are not checked, so a 404 or `FIRST_FRAME_KIND_MISMATCH` can first
        appear on `POST /videos`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VideoRequest" }
      responses:
        "200":
          description: Quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  mode: { type: string, enum: [talking_head, scenes] }
                  credits_required: { type: integer }
                  credits_balance: { type: integer }
                  sufficient_credits: { type: boolean }
                  estimated_seconds: { type: number }
                  billed_seconds: { type: integer, nullable: true }
                  resolution: { $ref: "#/components/schemas/Resolution" }
                  video_mode: { $ref: "#/components/schemas/VideoMode" }
                  voice_mode: { $ref: "#/components/schemas/VoiceMode" }
                  job_type: { $ref: "#/components/schemas/JobType" }
                  price_table_version: { type: string }
                  lines: { type: array, items: { $ref: "#/components/schemas/QuoteLine" } }
                  clips:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        render_seconds: { type: number }
                  breakdown:
                    type: object
                    description: "Always sums to credits_required. render_credits = generation + reference_allowance + transcription; lipsync_credits is 0."
                    properties:
                      render_credits: { type: integer }
                      lipsync_credits: { type: integer }
                      tts_credits: { type: integer }
                      first_frame_credits: { type: integer }
                      captions_credits: { type: integer }
                  warnings: { type: array, items: { $ref: "#/components/schemas/Warning" } }
              example:
                mode: "talking_head"
                credits_required: 125
                credits_balance: 900
                sufficient_credits: true
                estimated_seconds: 15.0
                billed_seconds: 15
                resolution: "768P"
                job_type: "talking_head_video"
                price_table_version: "2026-09-30"
                lines:
                  - { kind: generation, credits: 120, quantity: 15, unit: output_second }
                  - { kind: first_frame, credits: 4 }
                  - { kind: transcription, credits: 1 }
                clips: [{ index: 0, render_seconds: 15 }]
                breakdown: { render_credits: 121, lipsync_credits: 0, tts_credits: 0, first_frame_credits: 4, captions_credits: 0 }
                warnings: []
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422":
          description: audio_url could not be read as audio
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }

  /api/partner/v1/videos/scene-plan:
    post:
      tags: [Videos]
      summary: Preview the scenes a script will become (free)
      description: |
        New in 1.1.0. Free and instant. Uses the same planner as generation. Send the returned
        lines back as `scene_lines` on `POST /videos` to keep exactly these scenes (each line at
        most `max_words_per_scene` words).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [video_type, script]
              properties:
                video_type: { type: string, enum: [talking_head, scenes, moving_scene, normal_video] }
                script: { type: string, maxLength: 4000 }
                audio_duration_seconds: { type: number, maximum: 60.5, description: "Length of your recording, if any. Required (5-60) with voice_mode none." }
                voice_mode: { $ref: "#/components/schemas/VoiceMode" }
                language: { type: string }
            example:
              video_type: "talking_head"
              script: "Line one. Line two, a little longer than the first."
      responses:
        "200":
          description: Scenes
          content:
            application/json:
              schema:
                type: object
                properties:
                  scenes:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        line: { type: string }
                        seconds: { type: number }
                  max_words_per_scene: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/videos/{workflowId}/scenes:
    parameters:
      - $ref: "#/components/parameters/WorkflowId"
    get:
      tags: [Edits]
      summary: The scenes of a finished video (use the indices with the edit endpoints)
      description: New in 1.1.0. Only videos made with the current engines (`result.editable` is true on the job).
      responses:
        "200":
          description: Scenes view
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow_id: { type: string }
                  video_type: { $ref: "#/components/schemas/VideoType" }
                  resolution: { $ref: "#/components/schemas/Resolution" }
                  editable: { type: boolean }
                  poster_url: { type: string, format: uri }
                  voice:
                    type: object
                    properties:
                      source: { type: string, enum: [ai_voice, recording, none] }
                  scenes:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        line: { type: string }
                        seconds: { type: number }
                        start_seconds: { type: number }
                        talks: { type: boolean, description: "The presenter speaks on camera in this scene." }
                        clip_url: { type: string, format: uri }
                        poster_url: { type: string, format: uri }
                  source_workflow_id: { type: string, description: "Edits only: the video this one was made from." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/NotEditable" }

  /api/partner/v1/videos/{workflowId}/fix-scene:
    parameters:
      - $ref: "#/components/parameters/WorkflowId"
    post:
      tags: [Edits]
      summary: Fix one scene (makes a new video)
      description: |
        New in 1.1.0. Only the changed scene is made again. Talking head: no `description` = a new
        take of the scene; a `description` = a new frame for the scene first (+ the image price).
        Scenes video: `description` is required (`400 FIX_TEXT_REQUIRED`).
        `Idempotency-Key` (or `client_request_id`) is required unless `dry_run`. One open edit per
        source video at a time (`409 EDIT_IN_PROGRESS`; an edit idle for 30 minutes no longer blocks).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [scene_index]
              properties:
                scene_index: { type: integer, minimum: 0, maximum: 99 }
                description: { type: string, maxLength: 500, example: "make her smile at the end" }
                expected_credits: { type: integer, minimum: 0 }
                client_request_id: { type: string, format: uuid }
                dry_run: { type: boolean, description: "Return the quote only." }
      responses:
        "200": { $ref: "#/components/responses/EditQueued" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/EditConflict" }
        "429": { $ref: "#/components/responses/TooManyActiveJobs" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/TryAgain" }

  /api/partner/v1/videos/{workflowId}/edit-script:
    parameters:
      - $ref: "#/components/parameters/WorkflowId"
    post:
      tags: [Edits]
      summary: Give scenes new lines in the video's voice (makes a new video)
      description: |
        New in 1.1.0. Only for videos voiced from `voice_id` + `script` (else
        `400 VOICE_EDIT_UNAVAILABLE`). Each line must fit one scene (`400 LINE_TOO_LONG` with
        `max_words`). Same shared rules as fix-scene.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [lines]
              properties:
                lines:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items:
                    type: object
                    required: [scene_index, line]
                    properties:
                      scene_index: { type: integer, minimum: 0, maximum: 99 }
                      line: { type: string, maxLength: 600 }
                expected_credits: { type: integer, minimum: 0 }
                client_request_id: { type: string, format: uuid }
                dry_run: { type: boolean }
            example:
              lines: [{ scene_index: 0, line: "New opening line in the same voice." }]
      responses:
        "200": { $ref: "#/components/responses/EditQueued" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/EditConflict" }
        "429": { $ref: "#/components/responses/TooManyActiveJobs" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/TryAgain" }

  /api/partner/v1/videos/{workflowId}/fix-sound:
    parameters:
      - $ref: "#/components/parameters/WorkflowId"
    post:
      tags: [Edits]
      summary: Put a new voice take on a video (makes a new video)
      description: |
        New in 1.1.0. Send exactly one take: `audio_url`, `tts_asset_id` (one of your `tts_audio`
        assets) or `voice_id` + `script`. On a scenes video in which no scene speaks on camera, a
        take within ±300 ms of the video's length just swaps the sound, **free** (`plan: swap_audio`). Otherwise the video is made
        again at the take's length (`plan: regenerate`, paid).

        **A paid plan requires `expected_credits`** (at least the quote): without it the call is
        `409 QUOTE_MISMATCH` with `credits_quoted` and `plan`. Call with `dry_run: true` first to
        get the quote. `Idempotency-Key` (or `client_request_id`) is required unless `dry_run`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                audio_url: { type: string, format: uri }
                tts_asset_id: { type: string, format: uuid }
                voice_id: { type: string }
                script: { type: string, maxLength: 4000 }
                voice_settings: { $ref: "#/components/schemas/VoiceSettings" }
                language: { type: string }
                expected_credits: { type: integer, minimum: 0 }
                client_request_id: { type: string, format: uuid }
                dry_run: { type: boolean }
            example:
              audio_url: "https://cdn.example.com/new-take.mp3"
      responses:
        "200":
          description: Queued, or the quote when `dry_run` is true
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow_id: { type: string, example: "partner_fs_0d2a4b5c6d7e8f9a0b1c2d3e3b0f9c1e" }
                  status: { type: string, example: "queued" }
                  plan: { type: string, enum: [swap_audio, regenerate] }
                  credits_reserved: { type: integer }
                  credits_quoted: { type: integer }
                  source_workflow_id: { type: string }
                  job_type: { $ref: "#/components/schemas/JobType" }
                  price_table_version: { type: string }
                  duplicate: { type: boolean }
                  dry_run: { type: boolean }
                  lines: { type: array, items: { $ref: "#/components/schemas/QuoteLine" } }
                  video_ms: { type: integer, description: "dry_run only: the source video's length." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/EditConflict" }
        "422":
          description: The take could not be read as audio
          content:
            application/json: { schema: { $ref: "#/components/schemas/Error" } }
        "429": { $ref: "#/components/responses/TooManyActiveJobs" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/TryAgain" }

  /api/partner/v1/jobs/{workflowId}/status:
    parameters:
      - $ref: "#/components/parameters/WorkflowId"
    get:
      tags: [Jobs]
      summary: Poll a job's status
      responses:
        "200":
          description: Job status
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflow_id: { type: string }
                  status: { type: string, enum: [queued, running, completed, failed] }
                  error_message: { type: string, nullable: true }
                  error_code: { allOf: [{ $ref: "#/components/schemas/JobErrorCode" }], nullable: true }
                  credits_estimated: { type: integer, nullable: true }
                  credits_actual: { type: integer, nullable: true }
                  credits_consumed:
                    type: integer
                    nullable: true
                    description: "null while queued/running; actual_credits (falling back to estimated_credits) once completed; 0 on failed unless a partial actual_credits was recorded."
                  image_url: { type: string, nullable: true }
                  video_url: { type: string, nullable: true }
                  result:
                    type: object
                    nullable: true
                    description: "Result envelope once completed."
                    properties:
                      video_url: { type: string }
                      image_url: { type: string }
                      audio_url: { type: string }
                      asset_id: { type: string }
                      poster_url: { type: string }
                      editable: { type: boolean, description: "The video can be edited (fix-scene, edit-script, fix-sound)." }
                      scenes_url: { type: string }
                  job_type: { allOf: [{ $ref: "#/components/schemas/JobType" }], nullable: true }
                  phase: { allOf: [{ $ref: "#/components/schemas/Phase" }], nullable: true }
                  progress: { type: integer, nullable: true, minimum: 0, maximum: 100 }
                  poster_url: { type: string, nullable: true }
                  source_workflow_id: { type: string, nullable: true, description: "Edits: the video this one was made from." }
                  queued_at: { type: string, format: date-time }
                  completed_at: { type: string, nullable: true, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/assets:
    get:
      tags: [Assets]
      summary: List everything you've generated
      parameters:
        - name: kind
          in: query
          schema: { $ref: "#/components/schemas/AssetKind" }
        - name: limit
          in: query
          description: Page size; values above 100 are capped to 100.
          schema: { type: integer, default: 30 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Assets, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  assets:
                    type: array
                    items: { $ref: "#/components/schemas/Asset" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/assets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Assets]
      summary: Get one asset
      responses:
        "200":
          description: Asset
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Asset" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/partner/v1/usage/summary:
    get:
      tags: [Usage]
      summary: Credit balance and usage in a date range
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date-time }
          description: "Defaults to the first instant of the current UTC month."
        - name: to
          in: query
          schema: { type: string, format: date-time }
          description: "Defaults to now."
      responses:
        "200":
          description: Summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  credits_balance: { type: integer }
                  credits_balance_base: { type: integer }
                  credits_spent_lifetime: { type: integer, description: "Spend since the last top-up (base resets on each grant), not all-time." }
                  credits_consumed_in_range: { type: integer }
                  job_counts:
                    type: object
                    properties:
                      completed: { type: integer }
                      failed: { type: integer }
                      running_or_queued: { type: integer }
                      total_in_range: { type: integer }
                  from: { type: string, format: date-time }
                  to: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/usage/jobs:
    get:
      tags: [Usage]
      summary: Paginated job history
      parameters:
        - name: limit
          in: query
          description: Page size; values above 100 are capped to 100.
          schema: { type: integer, default: 30 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Jobs, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items:
                      type: object
                      properties:
                        workflow_id: { type: string }
                        status: { type: string }
                        credits_estimated: { type: integer, nullable: true }
                        credits_actual: { type: integer, nullable: true }
                        queued_at: { type: string, format: date-time }
                        completed_at: { type: string, nullable: true }
                        error_message: { type: string, nullable: true }
                        metadata: { type: object, description: "Public job facts only (phase, progress, resolution, video_type, poster_url, source_workflow_id, edit_kind, price_table_version, …)." }
                        job_type: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/partner/v1/billing/checkout-session:
    post:
      tags: [Billing]
      summary: Start a Stripe Checkout to buy credits
      description: |
        Creates a Stripe Checkout session and returns its `url`. No API key: the workspace is named by
        `billing_client_id` + `billing_sig` (from your billing link); without them the purchase is not
        tied to a workspace and is priced at the legacy rate.

        Pick the amount with exactly one of:
        - **Nothing, or `pack: "default"`**: the default pack. Charge **$19.99**; credits =
          floor(1999 × tier credits ÷ tier cents) for your workspace's price tier: **1,499** credits
          (current tier, 750 credits per $10) or **1,738** credits (legacy tier, 100 credits per $1.15).
        - `credits`: a multiple of 100, charged at your tier's rate.
        - `amount_cents` (or `amount_usd`): that charge; grants 1 credit per cent paid.

        `pack` cannot be combined with `credits`, `amount_cents` or `amount_usd`. Credits are granted
        when the payment completes.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [origin_base_url]
              properties:
                origin_base_url: { type: string, description: "HTTPS origin of your site; Checkout's cancel link returns here." }
                pack: { type: string, enum: [default], description: "The $19.99 default pack. Same as sending no amount." }
                credits: { oneOf: [{ type: integer }, { type: string }], description: "Credits to buy, a multiple of 100." }
                amount_cents: { oneOf: [{ type: integer }, { type: string }], description: "Charge in cents (whole number). Preferred over `amount_usd`." }
                amount_usd: { type: number, description: "Charge in USD. Ignored if `amount_cents` is set." }
                stripe_customer_id: { type: string, description: "Existing Stripe customer (`cus_…`) to attach the payment to." }
                billing_client_id: { type: string }
                billing_sig: { type: string }
            examples:
              defaultPack: { summary: "Default $19.99 pack", value: { origin_base_url: "https://example.com", billing_client_id: "<client id>", billing_sig: "<sig>" } }
              credits: { summary: "1,000 credits", value: { origin_base_url: "https://example.com", credits: 1000, billing_client_id: "<client id>", billing_sig: "<sig>" } }
      responses:
        "200":
          description: Checkout session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: "Stripe Checkout URL to redirect the buyer to." }
                  session_id: { type: string }
                  amount_cents: { type: integer, description: "Charge in cents (1999 for the default pack)." }
                  credits: { type: integer, description: "Credits granted when the payment completes." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: "`billing_client_id` / `billing_sig` don't match."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "502":
          description: Stripe could not create the session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "pmat_<12 hex>_<48 hex>"
      description: Full partner API key, e.g. `pmat_1a2b3c4d5e6f_...`
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: "Any 1-255 visible ASCII characters (a uuid, `order-123`, …). Same as the body's `client_request_id`; if both are sent they must match. Required on the edit endpoints unless `dry_run`."
      schema: { type: string, minLength: 1, maxLength: 255 }
    WorkflowId:
      name: workflowId
      in: path
      required: true
      description: "A job's workflow_id. Video ids start with `partner_h3t_` (talking head), `partner_h3v_` (scenes), `partner_se_` (fix-scene / edit-script) or `partner_fs_` (fix-sound); jobs made before 1.1.0 keep their ids."
      schema: { type: string }
  schemas:
    Error:
      type: object
      required: [error, error_code, message]
      properties:
        error: { type: string, description: "A pre-1.1.0 message string kept for compatibility, or the UPPER_SNAKE code." }
        error_code: { type: string, description: "The machine code (see ErrorCode), UPPER_SNAKE. On every error since 1.1.1." }
        message: { type: string, description: "Human-readable, safe to show. On every error since 1.1.1." }
        field: { type: string, description: "The request field at fault, when there is one." }
        code: { type: string, description: "Lowercase variant of the code on some responses (e.g. quote_mismatch, no_speech, voice_not_found); don't branch on it." }
    ErrorCode:
      type: string
      description: Machine codes in `error_code` (also in `error` for failures added since 1.1.0).
      enum: [UNAUTHORIZED, INVALID_BODY, INVALID_RESOLUTION, INVALID_SCENE_PHOTOS, INVALID_SCENE_LINES, INVALID_VIDEO_MODE, INVALID_VOICE_MODE, INVALID_VOICE_SETTINGS, INVALID_DURATION, INVALID_SCRIPT, INVALID_URL, AUDIO_TOO_SHORT, AUDIO_TOO_LONG, QUOTE_MISMATCH, IDEMPOTENCY_KEY_MISMATCH, IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_INVALID, INSUFFICIENT_CREDITS, TOO_MANY_ACTIVE_JOBS, START_FAILED, NOT_FOUND, VIDEO_NOT_READY, VIDEO_NOT_EDITABLE, EDIT_IN_PROGRESS, FIX_TEXT_REQUIRED, INVALID_SCENE, NO_CHANGES, EMPTY_LINE, LINE_TOO_LONG, VOICE_EDIT_UNAVAILABLE, TAKE_TOO_SHORT, TAKE_TOO_LONG, VOICE_UNAVAILABLE, NO_SPEECH, CLONE_UNAVAILABLE, FIRST_FRAME_KIND_MISMATCH, TRY_AGAIN, INTERNAL, INVALID_STUDIO_VOICE, NOT_A_CLONED_VOICE, VOICE_LIMIT_REACHED, INVALID_SAMPLES, FIRST_FRAME_NOT_VERTICAL, CHARACTER_IMAGE_UNAVAILABLE, FREE_CLONE_ATTEMPTS_EXHAUSTED]
    WarningCode:
      type: string
      enum: [RESOLUTION_MAPPED, FIELD_IGNORED, SCENE_PHOTOS_TRUNCATED, LOW_SAMPLE_AUDIO, SCENE_PHOTO_DROPPED]
    Warning:
      type: object
      properties:
        field: { type: string }
        code: { $ref: "#/components/schemas/WarningCode" }
        message: { type: string }
    JobErrorCode:
      type: string
      description: Why a job failed; `error_message` carries fixed, user-safe copy for the code.
      enum: [PROVIDER_MODERATION, PROVIDER_INPUT, PROVIDER_INFRA, PROVIDER_TIMEOUT, START_FAILED, STALE_TIMEOUT, INTERNAL, LINE_TOO_LONG, VOICE_UNAVAILABLE, FIX_NOT_APPLIED, MISSING_ASSET, AUDIO_TOO_LONG]
    Phase:
      type: string
      enum: [queued, preparing, generating, assembling, finalizing, completed, failed]
    JobType:
      type: string
      description: Public job type of a video job (other jobs keep their own names).
      enum: [talking_head_video, multi_scene_video, scene_edit_video, fix_sound_video]
    Resolution:
      type: string
      description: Output resolution as returned by the API.
      enum: [480P, 768P, 1080P]
    ResolutionInput:
      type: string
      description: "Any case. 720p is served as 768P (warning RESOLUTION_MAPPED). Default 768P (was 480p before 1.1.0)."
      enum: [480p, 720p, 1080p, 480P, 768P, 1080P]
    VideoMode:
      type: string
      enum: [reference, text]
    VoiceMode:
      type: string
      enum: [voice, none]
    VideoType:
      type: string
      enum: [talking_head, moving_scene]
    ScenePhotoRole:
      type: string
      enum: [setting, product, prop]
    CaptionsPosition:
      type: string
      enum: [top, center, bottom]
    EditKind:
      type: string
      enum: [new_take, new_frame, visual_fix, edit_script, swap_audio, regenerate]
    CharacterMode:
      type: string
      enum: [photos, description]
    PresenterGender:
      type: string
      enum: [female, male, unknown]
    HeadCovering:
      type: string
      enum: [hijab, niqab, headscarf, turban, kippah, none, unknown]
    AssetKind:
      type: string
      enum: [character_sheet, first_frame, tts_audio, video]
    QuoteLineKind:
      type: string
      enum: [generation, first_frame, reference_allowance, transcription, tts, captions, product]
    QuoteLine:
      type: object
      properties:
        kind: { $ref: "#/components/schemas/QuoteLineKind" }
        credits: { type: integer }
        quantity: { type: number }
        unit: { type: string }
    ScenePhoto:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri, description: "Public https image URL." }
        role: { $ref: "#/components/schemas/ScenePhotoRole" }
        caption: { type: string, maxLength: 200 }
    VoiceSettings:
      type: object
      description: "Optional. Values are clamped: speed to 0.7-1.2, the others to 0-1."
      properties:
        speed: { type: number }
        stability: { type: number }
        similarity_boost: { type: number }
        style: { type: number }
    VideoCreateRequest:
      description: The `POST /videos` body (`character_id` is required here; `/videos/estimate` takes VideoRequest).
      allOf:
        - $ref: "#/components/schemas/VideoRequest"
        - type: object
          required: [character_id]
    VideoRequest:
      type: object
      required: [mode]
      properties:
        mode: { type: string, enum: [talking_head, scenes, moving_scene], description: "`moving_scene` is an alias of `scenes`." }
        character_id: { type: string, format: uuid, description: "Required on POST /videos; not needed on /videos/estimate." }
        voice_id: { type: string, description: "With script: speech is made inside the job. Exactly one of {voice_id, script} or {audio_url} (unless voice_mode is none)." }
        script: { type: string, maxLength: 4000, description: "Spoken verbatim. At most 59.5 s of estimated speech." }
        audio_url: { type: string, format: uri, description: "Your own recording (https; anything else is ignored with a warning). 1-59.5 s. Allowed on both modes since 1.1.0." }
        video_idea: { type: string, maxLength: 2000, description: "What the video shows. talking_head: required unless a frame is supplied." }
        first_frame_url: { type: string, format: uri, description: "talking_head: the first frame. scenes (reference mode): the realistic photo of the character. https only; anything else is ignored with a warning." }
        first_frame_asset_id: { type: string, format: uuid, description: "One of your first_frame assets, made for the same video type (else 400 FIRST_FRAME_KIND_MISMATCH). Not with first_frame_url." }
        resolution: { $ref: "#/components/schemas/ResolutionInput" }
        video_mode: { allOf: [{ $ref: "#/components/schemas/VideoMode" }], description: "scenes only. text = from the words alone, no photos (scene photos and frames are ignored with a warning)." }
        voice_mode: { allOf: [{ $ref: "#/components/schemas/VoiceMode" }], description: "scenes only. none = a silent video of target_seconds." }
        target_seconds: { type: number, minimum: 5, maximum: 60, description: "Required with voice_mode none. The brief is script, else scene_lines, else video_idea." }
        scene_photos: { type: array, maxItems: 12, items: { $ref: "#/components/schemas/ScenePhoto" }, description: "Real stills woven into the video. Merged with scene_elements, de-duplicated, first 3 kept (warning SCENE_PHOTOS_TRUNCATED); more than 12 items or a malformed item is 400 INVALID_SCENE_PHOTOS. A role skips automatic labelling." }
        scene_elements: { type: array, maxItems: 12, items: { $ref: "#/components/schemas/ScenePhoto" }, description: "Before-1.1.0 name for scene_photos; still accepted and merged after scene_photos (malformed items skipped; items past 12 ignored)." }
        scene_prompt: { type: string, maxLength: 400, description: "The setting, in a few words." }
        scene_lines: { type: array, minItems: 1, maxItems: 15, items: { type: string, maxLength: 600 }, description: "The lines from POST /videos/scene-plan, to keep exactly those scenes." }
        voice_settings: { $ref: "#/components/schemas/VoiceSettings" }
        language: { type: string, description: "Spoken language, ISO code or name. Unknown = detected." }
        use_dynamic_captions: { type: boolean, description: "talking_head only. Burned-in dynamic captions, a flat add-on (10 credits). Ignored for scenes." }
        captions_language: { type: string, example: "ar", description: "talking_head only. Speech-language hint for captions." }
        captions_position: { allOf: [{ $ref: "#/components/schemas/CaptionsPosition" }], default: bottom, description: "talking_head only." }
        topic: { type: string, description: "Accepted (cut to 500) but has no effect: warning FIELD_IGNORED. Put the brief in video_idea / scene_prompt." }
        audience: { type: string, description: "Accepted (cut to 500) but has no effect: warning FIELD_IGNORED. Put the brief in video_idea / scene_prompt." }
        expected_credits: { type: integer, minimum: 0, description: "The price you confirmed. If the quote is higher: 409 QUOTE_MISMATCH with credits_quoted; nothing is reserved." }
        client_request_id: { type: string, format: uuid, description: "Idempotency key in the body (or use the Idempotency-Key header)." }
        fast: { type: boolean, deprecated: true, description: "Accepted and ignored (warning FIELD_IGNORED)." }
        narration_mode: { type: string, deprecated: true, description: "Accepted and ignored (warning FIELD_IGNORED)." }
    Asset:
      type: object
      properties:
        asset_id: { type: string, format: uuid }
        kind: { $ref: "#/components/schemas/AssetKind" }
        url: { type: string, format: uri }
        workflow_id: { type: string, nullable: true }
        character_id: { type: string, nullable: true }
        voice_id: { type: string, nullable: true }
        metadata:
          type: object
          description: "Public facts only. Videos: poster_url, duration_sec, resolution, video_type, video_mode, voice_mode, scene_count, source_workflow_id, edit_kind, price_table_version. Character sheets: mode, presenter_gender, head_covering. First frames: video_type, head_covering, scene_description."
        created_at: { type: string, format: date-time }
  responses:
    BadRequest:
      description: Validation error
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    NotFound:
      description: Not found (also returned when the resource belongs to a different client; existence is never leaked)
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    InsufficientCredits:
      description: Not enough credits
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: "INSUFFICIENT_CREDITS" }
              error_code: { type: string, example: "INSUFFICIENT_CREDITS" }
              message: { type: string }
              credits_required: { type: integer }
              credits_balance: { type: integer }
    TooManyActiveJobs:
      description: Too many active jobs
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: "TOO_MANY_ACTIVE_JOBS" }
              error_code: { type: string, example: "TOO_MANY_ACTIVE_JOBS" }
              message: { type: string }
              max_active_jobs: { type: integer }
    Conflict:
      description: "QUOTE_MISMATCH (`credits_quoted` is the price) or IDEMPOTENCY_KEY_REUSED (same key, different body)"
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, enum: [QUOTE_MISMATCH, IDEMPOTENCY_KEY_REUSED] }
              error_code: { type: string, enum: [QUOTE_MISMATCH, IDEMPOTENCY_KEY_REUSED] }
              message: { type: string }
              code: { type: string, example: "quote_mismatch" }
              credits_quoted: { type: integer }
    NotEditable:
      description: VIDEO_NOT_READY (not completed yet) or VIDEO_NOT_EDITABLE (made before 1.1.0 or on the previous engines)
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    EditConflict:
      description: "VIDEO_NOT_READY, VIDEO_NOT_EDITABLE, EDIT_IN_PROGRESS (one open edit per source video), QUOTE_MISMATCH (`credits_quoted`; fix-sound also returns `plan`) or IDEMPOTENCY_KEY_REUSED"
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, enum: [VIDEO_NOT_READY, VIDEO_NOT_EDITABLE, EDIT_IN_PROGRESS, QUOTE_MISMATCH, IDEMPOTENCY_KEY_REUSED] }
              error_code: { type: string, enum: [VIDEO_NOT_READY, VIDEO_NOT_EDITABLE, EDIT_IN_PROGRESS, QUOTE_MISMATCH, IDEMPOTENCY_KEY_REUSED] }
              code: { type: string }
              message: { type: string }
              credits_quoted: { type: integer }
              plan: { type: string, enum: [swap_audio, regenerate] }
    EditQueued:
      description: Queued (a new video), a duplicate of an earlier request, or the quote when `dry_run` is true
      content:
        application/json:
          schema:
            type: object
            properties:
              workflow_id: { type: string, example: "partner_se_3b0f9c1e0d2a4b5c6d7e8f9a0b1c2d3e" }
              status: { type: string, example: "queued" }
              credits_reserved: { type: integer }
              credits_quoted: { type: integer }
              job_type: { $ref: "#/components/schemas/JobType" }
              source_workflow_id: { type: string }
              scene_indices: { type: array, items: { type: integer } }
              edit_kind: { $ref: "#/components/schemas/EditKind" }
              price_table_version: { type: string }
              duplicate: { type: boolean }
              dry_run: { type: boolean }
              credits_required: { type: integer }
              credits_balance: { type: integer }
              sufficient_credits: { type: boolean }
              lines: { type: array, items: { $ref: "#/components/schemas/QuoteLine" } }
              clips:
                type: array
                description: Dry run of fix-scene / edit-script only.
                items:
                  type: object
                  properties:
                    index: { type: integer }
                    render_seconds: { type: number }
    ServerError:
      description: Server error (the job or call is refunded)
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
    TryAgain:
      description: "TRY_AGAIN: the service is briefly unavailable; nothing was charged"
      content:
        application/json: { schema: { $ref: "#/components/schemas/Error" } }
