openapi: 3.1.0
info:
  title: Mattecast Public API
  version: "1.0.0"
  summary: Mattecast — unified AI media generation API (image, video, audio, 3D, LLM, training).
  description: |
    Public REST API for the Mattecast platform — every model, every tool, one
    workspace. Friendly endpoints abstract the underlying provider catalog
    (WaveSpeed, FAL, BFL, OpenAI, ElevenLabs, etc.) behind a single interface
    with unified parameters, unified billing, and automatic provider fallback.

    Key principles:
      * One endpoint per surface (image, video, lipsync, …) — model selected via body field.
      * Cross-vendor unified parameters (`aspect_ratio`, `seed`, `duration_s`, `resolution`, `prompt`).
      * Async-by-default. Use `?wait=true` for sync responses (caps at 120 s).
      * Webhook callbacks via `webhook_url` body field, signed with HMAC-SHA256.
      * Progressive output via Server-Sent Events (`GET /v1/jobs/{id}/events`).
      * `Idempotency-Key` header for safe retries (24 h dedup window).

    Access. The public API is gated to **Pro tier and above** (Pro / Studio /
    Agency). API keys are issued from the `app.mattecast.com` dashboard.

    See `app-api-spec.md` for the human-readable reference and migration guide.
  contact:
    name: Mattecast developer support
    email: support@mattecast.com
    url: https://docs.mattecast.com
  license:
    name: Proprietary
    url: https://mattecast.com/terms
  termsOfService: https://mattecast.com/terms

servers:
  - url: https://api.mattecast.com/v1
    description: Production
  - url: https://sandbox.mattecast.com/v1
    description: Sandbox (test keys, no real credit charge — placeholder, not deployed yet)

security:
  - BearerAuth: []

tags:
  - name: Image
    description: Image generation, edit, and post-processing.
  - name: Video
    description: Video generation, edit, and post-processing.
  - name: Avatar
    description: Lipsync and talking-head pipelines.
  - name: Audio
    description: TTS, voice clone, music generation.
  - name: 3D
    description: 3D mesh generation from image, text, sketch, or multi-view.
  - name: LLM
    description: Prompt-enhance, captioning, moderation, transcription.
  - name: Training
    description: LoRA training jobs.
  - name: Marketing
    description: End-to-end marketing pipelines.
  - name: Jobs
    description: Job status, events, cancellation.
  - name: Account
    description: Credits, models catalog, webhook helpers.
  - name: BrandKits
    description: Reusable visual/copy/audience/psychology bundles that condition every generation. Starter+ only.

paths:
  # ────────────────── BRAND KITS ──────────────────
  /v1/brand-kits:
    get:
      tags: [BrandKits]
      operationId: brandKitsList
      summary: List brand kits for the current org
      parameters:
        - name: include_archived
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: Include 30-day grace-period archived kits.
      responses:
        "200":
          description: List of brand kits (summary form)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKitListResponse"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
    post:
      tags: [BrandKits]
      operationId: brandKitCreate
      summary: Create a new brand kit (charges 500 cr if over the included quota)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BrandKitCreateRequest"
      responses:
        "201":
          description: Brand kit created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKitWithCharge"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
        "403": { $ref: "#/components/responses/ErrorForbidden" }
  /v1/brand-kits/import-from-url:
    post:
      tags: [BrandKits]
      operationId: brandKitImportFromUrl
      summary: Generate a draft brand kit from a URL via LLM analysis (not saved)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Public URL — typically the brand's homepage. Read-only fetch, no JS exec.
      responses:
        "200":
          description: Draft kit (not saved)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKitImportResponse"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
  /v1/brand-kits/{id}:
    get:
      tags: [BrandKits]
      operationId: brandKitGet
      summary: Get a brand kit by id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Brand kit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKit"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
    patch:
      tags: [BrandKits]
      operationId: brandKitUpdate
      summary: Update a brand kit (any subset of fields)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BrandKitUpdateRequest"
      responses:
        "200":
          description: Updated kit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKit"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
    delete:
      tags: [BrandKits]
      operationId: brandKitDelete
      summary: Soft-delete a brand kit (30-day restore grace)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Soft-deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKit"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /v1/brand-kits/{id}/clone:
    post:
      tags: [BrandKits]
      operationId: brandKitClone
      summary: Deep-copy a brand kit under a new id (always charges 500 cr)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 80
                  description: Optional label for the clone (defaults to "<source label> (copy)").
      responses:
        "201":
          description: Cloned kit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKitWithCharge"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
  /v1/brand-kits/{id}/restore:
    post:
      tags: [BrandKits]
      operationId: brandKitRestore
      summary: Restore a soft-deleted brand kit within the 30-day grace window
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Restored kit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BrandKit"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorBrandKitLocked" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }

  # ────────────────── IMAGE ──────────────────
  /v1/image/generate:
    post:
      tags: [Image]
      operationId: imageGenerate
      summary: Generate an image from text and/or reference images
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageGenerateRequest"
      responses:
        "200":
          $ref: "#/components/responses/JobResponseSync"
        "202":
          $ref: "#/components/responses/JobResponseAsync"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/image/edit:
    post:
      tags: [Image]
      operationId: imageEdit
      summary: Edit an existing image (img2img, inpaint, relight, controlnet)
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageEditRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/image/upscale:
    post:
      tags: [Image]
      operationId: imageUpscale
      summary: Upscale an image to higher resolution
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageUpscaleRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/image/remove-background:
    post:
      tags: [Image]
      operationId: imageRemoveBackground
      summary: Remove the background from an image (returns PNG with alpha)
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageSingleInputRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/image/face-swap:
    post:
      tags: [Image]
      operationId: imageFaceSwap
      summary: Swap a face in an image with a reference face
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageFaceSwapRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/image/erase:
    post:
      tags: [Image]
      operationId: imageErase
      summary: Erase objects from an image (mask + reconstruction)
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageEraseRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/image/translate:
    post:
      tags: [Image]
      operationId: imageTranslate
      summary: Translate text inside an image to another language
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ImageTranslateRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }

  # ────────────────── VIDEO ──────────────────
  /v1/video/generate:
    post:
      tags: [Video]
      operationId: videoGenerate
      summary: Generate a video from text, image+text, or multi-image references
      description: |
        Single endpoint covering text-to-video, image-to-video, and reference-to-video.
        Mode is inferred from the body:
          * `prompt` only → text-to-video
          * `prompt` + `image` → image-to-video (single reference)
          * `prompt` + `images[]` (≥2) → reference-to-video (character consistency)
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoGenerateRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/video/edit:
    post:
      tags: [Video]
      operationId: videoEdit
      summary: Edit an existing video (style transfer, video-to-video)
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoEditRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/video/extend:
    post:
      tags: [Video]
      operationId: videoExtend
      summary: Extend a video with additional generated seconds
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoExtendRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/video/upscale:
    post:
      tags: [Video]
      operationId: videoUpscale
      summary: Upscale a video to higher resolution
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoUpscaleRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/video/face-swap:
    post:
      tags: [Video]
      operationId: videoFaceSwap
      summary: Swap a face throughout a video
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoFaceSwapRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/video/remove-background:
    post:
      tags: [Video]
      operationId: videoRemoveBackground
      summary: Remove background from each frame of a video
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoSingleInputRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/video/erase:
    post:
      tags: [Video]
      operationId: videoErase
      summary: Erase objects/people from a video
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoEraseRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/video/fps-up:
    post:
      tags: [Video]
      operationId: videoFpsUp
      summary: Increase video frame rate via interpolation
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VideoFpsUpRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }

  # ────────────────── AVATAR / AUDIO ──────────────────
  /v1/lipsync:
    post:
      tags: [Avatar]
      operationId: lipsync
      summary: Lipsync a video to an audio track (or text via TTS)
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LipsyncRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/tts:
    post:
      tags: [Audio]
      operationId: tts
      summary: Text-to-speech synthesis
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TtsRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }
  /v1/voice/clone:
    post:
      tags: [Audio]
      operationId: voiceClone
      summary: Clone a voice from an audio sample (returns a reusable voice_id)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VoiceCloneRequest"
      responses:
        "201":
          description: Voice created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VoiceCloneResponse"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
  /v1/music/generate:
    post:
      tags: [Audio]
      operationId: musicGenerate
      summary: Generate music from a text prompt
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MusicGenerateRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }

  # ────────────────── 3D ──────────────────
  /v1/3d/generate:
    post:
      tags: [3D]
      operationId: threeDGenerate
      summary: Generate a 3D mesh from image, text, sketch, or multi-view
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ThreeDGenerateRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }

  # ────────────────── LLM ──────────────────
  /v1/llm/enhance-prompt:
    post:
      tags: [LLM]
      operationId: llmEnhancePrompt
      summary: Enhance a raw prompt for image/video models
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnhancePromptRequest"
      responses:
        "200":
          description: Enhanced prompt
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnhancePromptResponse"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
  /v1/llm/caption:
    post:
      tags: [LLM]
      operationId: llmCaption
      summary: Caption an image or video, or answer a question about it
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CaptionRequest"
      responses:
        "200":
          description: Caption / answer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CaptionResponse"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
  /v1/llm/moderate:
    post:
      tags: [LLM]
      operationId: llmModerate
      summary: Run content moderation on text, image, or video
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ModerateRequest"
      responses:
        "200":
          description: Moderation result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModerateResponse"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
  /v1/llm/transcribe:
    post:
      tags: [LLM]
      operationId: llmTranscribe
      summary: Transcribe audio or video to text
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TranscribeRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }

  # ────────────────── TRAINING ──────────────────
  /v1/lora/train:
    post:
      tags: [Training]
      operationId: loraTrain
      summary: Start a LoRA training job
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoraTrainRequest"
      responses:
        "202":
          description: Training job queued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobResponse"
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }

  # ────────────────── MARKETING ──────────────────
  /v1/marketing/ad:
    post:
      tags: [Marketing]
      operationId: marketingAd
      summary: Marketing Studio pipeline (URL + persona + preset → ad video)
      parameters:
        - $ref: "#/components/parameters/Wait"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MarketingAdRequest"
      responses:
        "200": { $ref: "#/components/responses/JobResponseSync" }
        "202": { $ref: "#/components/responses/JobResponseAsync" }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "402": { $ref: "#/components/responses/ErrorInsufficientCredits" }
        "429": { $ref: "#/components/responses/ErrorRateLimit" }
        "503": { $ref: "#/components/responses/ErrorModelUnavailable" }

  # ────────────────── JOBS ──────────────────
  /v1/jobs/{id}:
    get:
      tags: [Jobs]
      operationId: jobGet
      summary: Get the current status of a job
      parameters:
        - name: id
          in: path
          required: true
          description: Job identifier returned at submission time.
          schema: { type: string }
      responses:
        "200":
          description: Job status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobResponse"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
    delete:
      tags: [Jobs]
      operationId: jobCancel
      summary: Cancel a queued or in-progress job
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Cancellation acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobResponse"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }
        "409":
          description: Job already terminal — cannot cancel
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /v1/jobs/{id}/events:
    get:
      tags: [Jobs]
      operationId: jobEvents
      summary: Server-Sent Events stream of job progress
      description: |
        Returns a `text/event-stream` connection. Events: `queued`, `started`,
        `progress`, `output_chunk`, `succeeded`, `failed`, `canceled`. The stream
        closes after the first terminal event.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: cursor
          in: query
          required: false
          description: Resume from a prior event id (Last-Event-ID also honored).
          schema: { type: string }
      responses:
        "200":
          description: Event stream
          content:
            text/event-stream:
              schema:
                type: string
                description: Server-Sent Events stream. See `app-api-spec.md` §6.
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
        "404": { $ref: "#/components/responses/ErrorNotFound" }

  # ────────────────── ACCOUNT ──────────────────
  /v1/credits:
    get:
      tags: [Account]
      operationId: creditsGet
      summary: Current credit balance and usage summary
      responses:
        "200":
          description: Balance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditsResponse"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /v1/models:
    get:
      tags: [Account]
      operationId: modelsList
      summary: List available friendly model names per surface
      parameters:
        - name: surface
          in: query
          required: false
          description: Filter to a specific surface (`image`, `video`, `lipsync`, `tts`, `music`, `3d`, `llm`).
          schema:
            type: string
            enum: [image, video, lipsync, tts, music, "3d", llm, training]
      responses:
        "200":
          description: Model catalog
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ModelsListResponse"
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }
  /v1/webhooks/test:
    post:
      tags: [Account]
      operationId: webhookTest
      summary: Send a test webhook delivery to verify URL + signature
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [webhook_url]
              properties:
                webhook_url:
                  type: string
                  format: uri
                  description: Target URL to receive the test delivery.
                payload:
                  type: object
                  additionalProperties: true
                  description: Optional custom JSON payload (otherwise a fixture is used).
      responses:
        "200":
          description: Delivery attempted
          content:
            application/json:
              schema:
                type: object
                required: [delivered, status_code, latency_ms, signature]
                properties:
                  delivered: { type: boolean }
                  status_code: { type: integer }
                  latency_ms: { type: integer }
                  signature:
                    type: string
                    description: The HMAC-SHA256 signature your endpoint received.
                  attempt_id: { type: string }
        "400": { $ref: "#/components/responses/ErrorBadRequest" }
        "401": { $ref: "#/components/responses/ErrorUnauthorized" }

# ────────────────── COMPONENTS ──────────────────
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key (prefix `mc_live_…` for production or `mc_test_…` for sandbox)
      description: |
        API key with prefix `mc_live_…` (production) or `mc_test_…` (sandbox).
        Requires Pro+ subscription (Pro / Studio / Agency tier).

  parameters:
    Wait:
      name: wait
      in: query
      required: false
      description: |
        If `true`, the request blocks until the job reaches a terminal state or 120 s
        elapse, whichever comes first. Returns 200 with full output on completion,
        202 with `status: "in_progress"` if the timeout is hit (poll `/v1/jobs/{id}`).
      schema:
        type: boolean
        default: false
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-supplied UUID or arbitrary string ≤255 chars. The same key submitted
        within 24 h returns the original job's response (and is not re-charged).
      schema:
        type: string
        maxLength: 255

  responses:
    JobResponseSync:
      description: Job completed synchronously (sync mode only)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/JobResponse"
    JobResponseAsync:
      description: Job queued — poll `GET /v1/jobs/{id}` or subscribe via SSE/webhook
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/JobResponse"
      headers:
        Location:
          description: URL of the job resource.
          schema: { type: string }
    ErrorBadRequest:
      description: Validation error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ErrorUnauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ErrorForbidden:
      description: Plan does not permit this action
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ErrorNotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ErrorInsufficientCredits:
      description: Account lacks credits / spend cap exceeded
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ErrorRateLimit:
      description: Rate limit exceeded
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ErrorModelUnavailable:
      description: All providers in the fallback chain are unavailable
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    ErrorBrandKitLocked:
      description: |
        Brand Kits feature requires a Starter+ subscription. Free / PAYG accounts
        receive `brand_kit_locked`. Returned both for direct brand-kit endpoints
        and for generation requests that include `brand_kit_id` from a non-eligible
        plan. Also covers `brand_kit_quota_exceeded` (hard 50/org cap or insufficient
        credits for the 500-cr add-on).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

  schemas:
    # ─── Common ───
    AspectRatio:
      type: string
      description: Output aspect ratio. Models that don't natively support a ratio are letterboxed/cropped server-side.
      enum: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9", "auto"]
      default: "auto"
    Resolution:
      type: string
      description: Output resolution (image short-edge or video target).
      enum: ["480p", "720p", "1080p", "2k", "4k", "auto"]
      default: "auto"
    Seed:
      type: integer
      description: Random seed for reproducibility. Omit for random. Range 0..2^53−1.
      minimum: 0
    WebhookUrl:
      type: string
      format: uri
      description: HTTPS URL that receives a signed POST when the job reaches a terminal state.
    Metadata:
      type: object
      additionalProperties:
        type: string
      maxProperties: 20
      description: Arbitrary string key/value pairs echoed back in job + webhook payloads.
    InputUrl:
      type: string
      format: uri
      description: Public HTTPS URL of an input asset, OR a `wsapp://...` reference returned by a prior job.

    JobStatus:
      type: string
      enum: [queued, in_progress, succeeded, failed, canceled]
    JobOutput:
      type: object
      required: [type, url]
      properties:
        type:
          type: string
          enum: [image, video, audio, mesh, text, json]
        url:
          type: string
          format: uri
          description: HTTPS URL hosted on our CDN. Available for ≥7 days.
        mime_type: { type: string }
        bytes:
          type: integer
          format: int64
        width: { type: integer }
        height: { type: integer }
        duration_s: { type: number }
        seed: { type: integer }
    JobResponse:
      type: object
      required: [id, object, status, surface, created_at]
      properties:
        id:
          type: string
          example: job_01HF8X2K7Z9Q3W6V5R4Y8M0NPB
        object:
          type: string
          enum: [job]
        surface:
          type: string
          description: The endpoint family that created this job.
          enum: [image, video, lipsync, tts, voice_clone, music, "3d", llm, training, marketing]
        operation:
          type: string
          description: Specific operation within the surface (e.g. `generate`, `edit`, `upscale`).
        model:
          type: string
          description: Friendly model name actually used (may differ from request if a fallback fired).
          example: flux-2-pro
        status: { $ref: "#/components/schemas/JobStatus" }
        progress:
          type: number
          minimum: 0
          maximum: 1
          description: 0..1 fractional progress (best effort).
        outputs:
          type: array
          items: { $ref: "#/components/schemas/JobOutput" }
        credits_reserved:
          type: integer
          description: Credits held against your balance until terminal state.
        credits_charged:
          type: integer
          description: Final credit charge — present when `status=succeeded`.
        provider_used:
          type: string
          description: Internal provider identifier (`wavespeed`, `fal`, `bfl`, `openai`, `elevenlabs`).
        error:
          $ref: "#/components/schemas/Error"
        request_id:
          type: string
          description: Server-side request id for support tickets.
        idempotency_key:
          type: string
        metadata:
          $ref: "#/components/schemas/Metadata"
        created_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time

    # ─── Errors ───
    Error:
      type: object
      required: [code, message, request_id]
      properties:
        code:
          type: string
          description: Stable machine-readable error code. See `app-api-spec.md` §2.
          enum:
            - auth_missing
            - auth_invalid
            - auth_revoked
            - permission_denied
            - validation_failed
            - unsupported_model
            - unsupported_param
            - input_too_large
            - input_unreadable
            - moderation_blocked
            - insufficient_credits
            - spend_cap_reached
            - rate_limit
            - concurrency_limit
            - model_unavailable
            - provider_timeout
            - provider_error
            - job_not_found
            - job_already_terminal
            - idempotency_conflict
            - internal_error
            - brand_kit_locked
            - brand_kit_quota_exceeded
            - brand_kit_not_found
            - brand_kit_archived
            - brand_kit_banned_word_in_output
        message:
          type: string
          description: Human-readable message — not a stable contract; switch on `code`.
        request_id:
          type: string
        doc_url:
          type: string
          format: uri
        param:
          type: string
          description: For `validation_failed` — the offending body field (JSON path).
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/Error" }

    # ─── Image ───
    ImageGenerateRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          maxLength: 4000
        negative_prompt:
          type: string
          maxLength: 1000
        model:
          type: string
          description: Friendly model name. See `GET /v1/models?surface=image`. Omit for plan default (`flux-2-pro`).
          example: flux-2-pro
          enum:
            - flux-2-pro
            - flux-2-klein
            - flux-kontext-max
            - seedream-v4
            - imagen4
            - ideogram-v3
            - nano-banana-pro
            - midjourney-v7
            - higgsfield-soul
            - qwen-image
            - auto
        aspect_ratio: { $ref: "#/components/schemas/AspectRatio" }
        resolution: { $ref: "#/components/schemas/Resolution" }
        seed: { $ref: "#/components/schemas/Seed" }
        n:
          type: integer
          minimum: 1
          maximum: 8
          default: 1
          description: Number of images to generate. Multiplies the credit cost.
        images:
          type: array
          description: Up to 4 reference images for character/style consistency (when supported).
          items: { $ref: "#/components/schemas/InputUrl" }
          maxItems: 4
        enhance_prompt:
          type: boolean
          default: false
          description: Run prompt-optimizer before generation.
        brand_kit_id:
          type: string
          description: Optional brand kit ID; when set, conditions generation per kit fields (palette + mood_keywords appended to prompt; reference_images as i2i refs where supported; banned_words filter on text-in-image output). See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    ImageEditRequest:
      type: object
      required: [image, prompt]
      properties:
        image: { $ref: "#/components/schemas/InputUrl" }
        prompt:
          type: string
          maxLength: 4000
        mask:
          allOf:
            - $ref: "#/components/schemas/InputUrl"
          description: Optional alpha mask (white = edit, black = keep). Triggers inpaint mode.
        model:
          type: string
          enum:
            - flux-kontext-max
            - seedream-v4-edit
            - nano-banana-pro
            - qwen-image-edit
            - higgsfield-soul
            - ic-light
            - bria-fibo-relight
            - auto
          example: flux-kontext-max
        operation:
          type: string
          description: Optional hint — `edit`, `inpaint`, `outpaint`, `relight`, `controlnet`, `multi-angle`.
          enum: [edit, inpaint, outpaint, relight, controlnet, multi-angle]
        controlnet:
          type: string
          enum: [canny, depth, pose]
        seed: { $ref: "#/components/schemas/Seed" }
        brand_kit_id:
          type: string
          description: Optional brand kit ID; when set, conditions generation per kit fields. See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    ImageSingleInputRequest:
      type: object
      required: [image]
      properties:
        image: { $ref: "#/components/schemas/InputUrl" }
        model:
          type: string
          example: image-bg-remover
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    ImageUpscaleRequest:
      allOf:
        - $ref: "#/components/schemas/ImageSingleInputRequest"
        - type: object
          properties:
            scale:
              type: integer
              enum: [2, 4, 8]
              default: 2
            target_resolution: { $ref: "#/components/schemas/Resolution" }
            model:
              type: string
              enum: [image-upscaler, ultimate-image-upscaler, auto]
              default: image-upscaler
    ImageFaceSwapRequest:
      type: object
      required: [target, face]
      properties:
        target:
          $ref: "#/components/schemas/InputUrl"
        face:
          $ref: "#/components/schemas/InputUrl"
        model:
          type: string
          enum: [face-swap, face-swap-pro, head-swap, body-swap, multi-face, auto]
          default: face-swap
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    ImageEraseRequest:
      type: object
      required: [image, mask]
      properties:
        image: { $ref: "#/components/schemas/InputUrl" }
        mask:
          allOf:
            - $ref: "#/components/schemas/InputUrl"
          description: White areas are erased and reconstructed.
        model:
          type: string
          enum: [image-eraser, image-text-remover, image-watermark-remover, auto]
          default: image-eraser
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    ImageTranslateRequest:
      type: object
      required: [image, target_language]
      properties:
        image: { $ref: "#/components/schemas/InputUrl" }
        target_language:
          type: string
          description: ISO-639-1 code, e.g. `en`, `es`, `ja`, `ar`.
        source_language:
          type: string
          description: Optional source language hint; auto-detected if omitted.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }

    # ─── Video ───
    VideoGenerateRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          maxLength: 4000
        negative_prompt:
          type: string
          maxLength: 1000
        image:
          allOf:
            - $ref: "#/components/schemas/InputUrl"
          description: Single reference image — switches to image-to-video mode.
        images:
          type: array
          items: { $ref: "#/components/schemas/InputUrl" }
          maxItems: 4
          description: 2–4 references — switches to reference-to-video / character-consistency mode.
        model:
          type: string
          enum:
            - sora-2
            - sora-2-pro
            - veo-3.1
            - veo-3.1-fast
            - kling-3.0-std
            - kling-3.0-pro
            - seedance-2.0
            - seedance-2.0-fast
            - wan-2.7
            - wan-2.6-flash
            - hailuo-2.3
            - pika-v2.2
            - luma-ray-2
            - higgsfield-dop
            - auto
          example: seedance-2.0-fast
        aspect_ratio: { $ref: "#/components/schemas/AspectRatio" }
        resolution: { $ref: "#/components/schemas/Resolution" }
        duration_s:
          type: number
          minimum: 1
          maximum: 30
          default: 5
        fps:
          type: integer
          enum: [24, 30, 60]
          default: 24
        seed: { $ref: "#/components/schemas/Seed" }
        camera:
          type: string
          description: Camera-move preset (subset of models that support it).
          enum: [none, dolly-in, dolly-out, push-in, pull-out, orbit, crane-up, crane-down, handheld]
        with_audio:
          type: boolean
          description: Generate native audio (Sora 2, Veo 3.1).
          default: false
        enhance_prompt:
          type: boolean
          default: false
        brand_kit_id:
          type: string
          description: Optional brand kit ID; when set, conditions generation per kit fields (palette + mood_keywords appended to prompt; reference_images as I2V conditioning where supported; archetype biases camera-move defaults). See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    VideoEditRequest:
      type: object
      required: [video, prompt]
      properties:
        video: { $ref: "#/components/schemas/InputUrl" }
        prompt:
          type: string
          maxLength: 4000
        model:
          type: string
          enum: [seedance-2.0-edit, runway-aleph, auto]
          default: auto
        seed: { $ref: "#/components/schemas/Seed" }
        brand_kit_id:
          type: string
          description: Optional brand kit ID; when set, conditions edit per kit fields. See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    VideoExtendRequest:
      type: object
      required: [video]
      properties:
        video: { $ref: "#/components/schemas/InputUrl" }
        prompt:
          type: string
          maxLength: 4000
        added_duration_s:
          type: number
          minimum: 1
          maximum: 15
          default: 5
        model:
          type: string
          enum: [wan-2.7-extend, seedance-2.0-extend, auto]
          default: auto
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    VideoSingleInputRequest:
      type: object
      required: [video]
      properties:
        video: { $ref: "#/components/schemas/InputUrl" }
        model:
          type: string
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    VideoUpscaleRequest:
      allOf:
        - $ref: "#/components/schemas/VideoSingleInputRequest"
        - type: object
          properties:
            target_resolution:
              $ref: "#/components/schemas/Resolution"
            model:
              type: string
              enum: [flashvsr, ultimate-video-upscaler, video-upscaler-pro, auto]
              default: flashvsr
    VideoFaceSwapRequest:
      type: object
      required: [target, face]
      properties:
        target: { $ref: "#/components/schemas/InputUrl" }
        face: { $ref: "#/components/schemas/InputUrl" }
        model:
          type: string
          enum: [video-face-swap, video-face-swap-pro, auto]
          default: video-face-swap
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    VideoEraseRequest:
      type: object
      required: [video, mask]
      properties:
        video: { $ref: "#/components/schemas/InputUrl" }
        mask:
          allOf:
            - $ref: "#/components/schemas/InputUrl"
          description: First-frame mask OR a per-frame mask track.
        model:
          type: string
          enum: [video-eraser, video-watermark-remover, auto]
          default: video-eraser
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    VideoFpsUpRequest:
      allOf:
        - $ref: "#/components/schemas/VideoSingleInputRequest"
        - type: object
          properties:
            target_fps:
              type: integer
              enum: [30, 60, 120]
              default: 60
            model:
              type: string
              enum: [rife, auto]
              default: rife

    # ─── Lipsync / Audio ───
    LipsyncRequest:
      type: object
      required: [video]
      description: Provide either `audio` OR (`text` + `voice_id` for TTS-then-lipsync).
      properties:
        video: { $ref: "#/components/schemas/InputUrl" }
        audio:
          $ref: "#/components/schemas/InputUrl"
        text:
          type: string
          maxLength: 5000
          description: If supplied (with `voice_id`), runs TTS then lipsync in one call.
        voice_id:
          type: string
          description: Voice library id (built-in or from `/v1/voice/clone`).
        model:
          type: string
          enum: [sync-v3, sync-v2, kling-lipsync, latentsync, auto]
          default: sync-v3
        brand_kit_id:
          type: string
          description: Optional brand kit ID; when supplied, the kit's tone informs voice selection (when `voice_id` is omitted) and `banned_words` are checked on the resulting transcript. See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    TtsRequest:
      type: object
      required: [text]
      properties:
        text:
          type: string
          maxLength: 10000
        voice_id:
          type: string
          description: Built-in voice id or your cloned voice id.
        model:
          type: string
          enum: [elevenlabs-v3, elevenlabs-turbo, elevenlabs-flash, qwen3-tts, omnivoice, auto]
          default: elevenlabs-turbo
        language:
          type: string
        speed:
          type: number
          minimum: 0.5
          maximum: 2.0
          default: 1.0
        format:
          type: string
          enum: [mp3, wav, ogg, pcm]
          default: mp3
        brand_kit_id:
          type: string
          description: Optional brand kit ID; when set, kit `tone` informs voice selection if `voice_id` is omitted, and `banned_words` filter checks the input text. See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }
    VoiceCloneRequest:
      type: object
      required: [name, samples]
      properties:
        name:
          type: string
          maxLength: 80
        samples:
          type: array
          minItems: 1
          maxItems: 10
          items: { $ref: "#/components/schemas/InputUrl" }
        consent_attestation:
          type: boolean
          description: Must be `true` — confirms you have rights to clone this voice.
        model:
          type: string
          enum: [elevenlabs, qwen3-voice-clone, auto]
          default: elevenlabs
    VoiceCloneResponse:
      type: object
      required: [voice_id, name, model, created_at]
      properties:
        voice_id: { type: string, example: voice_01HF8X9R3K2W7P6N4Y5Q8M0NXB }
        name: { type: string }
        model: { type: string }
        sample_count: { type: integer }
        created_at: { type: string, format: date-time }
    MusicGenerateRequest:
      type: object
      required: [prompt]
      properties:
        prompt:
          type: string
          maxLength: 1000
        duration_s:
          type: number
          minimum: 5
          maximum: 240
          default: 30
        model:
          type: string
          enum: [elevenlabs-music, ace-step, song-generation, auto]
          default: elevenlabs-music
        instrumental:
          type: boolean
          default: false
        format:
          type: string
          enum: [mp3, wav]
          default: mp3
        brand_kit_id:
          type: string
          description: Optional brand kit ID; when set, kit `mood_keywords` are appended to the music prompt. See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }

    # ─── 3D ───
    ThreeDGenerateRequest:
      type: object
      required: [kind]
      description: Body shape varies by `kind`. Validation is conditional.
      properties:
        kind:
          type: string
          enum: [image-to-3d, text-to-3d, multiview-to-3d, sketch-to-3d]
        prompt:
          type: string
          maxLength: 2000
          description: Required for `text-to-3d`; optional hint for image/multiview.
        image: { $ref: "#/components/schemas/InputUrl" }
        images:
          type: array
          items: { $ref: "#/components/schemas/InputUrl" }
          minItems: 4
          maxItems: 4
          description: Required for `multiview-to-3d` (front/back/left/right).
        model:
          type: string
          enum: [tripo3d-v2.5, tripo3d-h3.1, hunyuan3d-v3, meshy-6, hyper3d-rodin-v2, sam-3d, auto]
          default: tripo3d-v2.5
        format:
          type: string
          enum: [glb, fbx, obj, stl, usdz]
          default: glb
        pbr:
          type: boolean
          default: true
        symmetry:
          type: string
          enum: [auto, on, off]
          default: auto
        brand_kit_id:
          type: string
          description: Optional brand kit ID; kit `mood_keywords` appended to the text-to-3D prompt. See `app-brand-kits-spec.md` §6. Starter+ only.
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }

    # ─── LLM ───
    EnhancePromptRequest:
      type: object
      required: [prompt, target]
      properties:
        prompt: { type: string, maxLength: 4000 }
        target:
          type: string
          description: What kind of generator the prompt is for.
          enum: [image, video, music, "3d"]
        model:
          type: string
          enum: [prompt-optimizer, molmo2, auto]
          default: prompt-optimizer
    EnhancePromptResponse:
      type: object
      required: [prompt, original]
      properties:
        prompt: { type: string }
        original: { type: string }
        credits_charged: { type: integer }
        request_id: { type: string }
    CaptionRequest:
      type: object
      required: [media]
      properties:
        media: { $ref: "#/components/schemas/InputUrl" }
        media_kind:
          type: string
          enum: [image, video]
          default: image
        question:
          type: string
          description: Optional question — switches to VQA mode.
        model:
          type: string
          enum: [molmo2, moondream3, minicpm-v, video-understanding, auto]
          default: auto
    CaptionResponse:
      type: object
      required: [text]
      properties:
        text: { type: string }
        credits_charged: { type: integer }
        request_id: { type: string }
    ModerateRequest:
      type: object
      properties:
        text: { type: string }
        media: { $ref: "#/components/schemas/InputUrl" }
        media_kind:
          type: string
          enum: [image, video]
        categories:
          type: array
          items:
            type: string
            enum: [sexual, violence, hate, self_harm, illegal, csam, pii]
      anyOf:
        - required: [text]
        - required: [media]
    ModerateResponse:
      type: object
      required: [allowed, scores]
      properties:
        allowed: { type: boolean }
        scores:
          type: object
          additionalProperties:
            type: number
            minimum: 0
            maximum: 1
        flagged_categories:
          type: array
          items: { type: string }
        request_id: { type: string }
    TranscribeRequest:
      type: object
      required: [media]
      properties:
        media: { $ref: "#/components/schemas/InputUrl" }
        language:
          type: string
          description: Optional ISO-639-1 hint.
        model:
          type: string
          enum: [whisper, whisper-turbo, whisper-with-video, auto]
          default: whisper-turbo
        timestamps:
          type: boolean
          default: false
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }

    # ─── Training ───
    LoraTrainRequest:
      type: object
      required: [name, kind, dataset]
      properties:
        name:
          type: string
          maxLength: 80
        kind:
          type: string
          enum: [flux-image, qwen-image, wan-video, ltx-video]
        dataset:
          type: array
          minItems: 5
          maxItems: 200
          items:
            type: object
            required: [url]
            properties:
              url: { $ref: "#/components/schemas/InputUrl" }
              caption: { type: string }
        trigger_word:
          type: string
        steps:
          type: integer
          minimum: 200
          maximum: 4000
          default: 1000
        rank:
          type: integer
          enum: [4, 8, 16, 32, 64]
          default: 16
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }

    # ─── Marketing ───
    MarketingAdRequest:
      type: object
      required: [product_url, preset]
      properties:
        product_url:
          type: string
          format: uri
          description: Public product URL — we scrape image + copy automatically.
        preset:
          type: string
          enum:
            - ugc-unboxing
            - ugc-hook
            - tutorial
            - asmr
            - virtual-try-on
            - product-review
            - hyper-motion
            - tv-spot
            - lifestyle
            - click-to-ad
        persona:
          type: object
          properties:
            avatar_id: { type: string, description: Saved avatar from your library. }
            voice_id: { type: string }
            description: { type: string, maxLength: 500 }
        brand_kit_id:
          type: string
          description: Optional brand kit ID; full kit (visual + copy + audience + psychology) drives the Marketing Studio pipeline (palette + logo composited into export, taglines surface as overlay options, full audience/psychology block drives ad-copy LLM). See `app-brand-kits-spec.md` §6. Starter+ only.
        aspect_ratio:
          allOf:
            - $ref: "#/components/schemas/AspectRatio"
          default: "9:16"
        duration_s:
          type: number
          default: 8
        variations:
          type: integer
          minimum: 1
          maximum: 6
          default: 3
        webhook_url: { $ref: "#/components/schemas/WebhookUrl" }
        metadata: { $ref: "#/components/schemas/Metadata" }

    # ─── Account ───
    CreditsResponse:
      type: object
      required: [balance, plan]
      properties:
        balance:
          type: integer
          description: Credits currently available (1 credit = $0.01 user-facing).
        reserved:
          type: integer
          description: Credits held by in-flight jobs (deducted from balance).
        plan:
          type: string
          enum: [free, starter, creator, pro, studio, agency, enterprise]
        plan_renews_at:
          type: string
          format: date-time
        period_usage:
          type: integer
        period_quota:
          type: integer
        spend_cap_usd:
          type: number
          nullable: true
    ModelEntry:
      type: object
      required: [name, surface, status]
      properties:
        name: { type: string, example: flux-2-pro }
        surface: { type: string, example: image }
        status:
          type: string
          enum: [available, beta, deprecated, retired]
        credits_min:
          type: integer
          description: Minimum credits charged per call (parameter-dependent).
        description: { type: string }
        defaults_for:
          type: array
          description: Surfaces where this model is the default selection.
          items: { type: string }
        fallback_chain:
          type: array
          items: { type: string }
          description: Provider fallback order applied when the primary errors.
    ModelsListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/ModelEntry" }

    # ─── Brand Kits ───
    BrandArchetype:
      type: string
      description: Jungian brand archetype. Locked enum.
      enum:
        - Hero
        - Sage
        - Outlaw
        - Creator
        - Caregiver
        - Magician
        - Ruler
        - Innocent
        - Explorer
        - Lover
        - Jester
        - Everyman
    BrandKitVisual:
      type: object
      required: [palette, typography_hint, mood_keywords, reference_images]
      properties:
        palette:
          type: array
          items:
            type: string
            description: Hex color string (e.g. "#1c1c1c").
            pattern: '^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$'
          maxItems: 8
        typography_hint:
          type: string
          maxLength: 200
          description: Free-text typography guidance (e.g. "Editorial serif + neo-grotesque sans").
        logo_url:
          type: string
          format: uri
          description: |
            R2 reference under the owner's org path. External URLs are fetched once,
            scanned, then copied into our R2 — `logo_url` always resolves to a
            `cdn.mattecast.com/o/...` URL after save.
        mood_keywords:
          type: array
          items: { type: string, maxLength: 40 }
          maxItems: 12
        reference_images:
          type: array
          description: R2 refs (≤6) used as i2i / I2V conditioning for models that support reference inputs.
          items: { $ref: "#/components/schemas/InputUrl" }
          maxItems: 6
    BrandKitCopy:
      type: object
      required: [tone, voice_keywords, hero_taglines, banned_words]
      properties:
        tone:
          type: string
          maxLength: 300
          description: Free-text voice/tone description (e.g. "Confident, terse, slightly cheeky").
        voice_keywords:
          type: array
          items: { type: string, maxLength: 40 }
          maxItems: 12
        hero_taglines:
          type: array
          items: { type: string, maxLength: 120 }
          maxItems: 5
        banned_words:
          type: array
          items: { type: string, maxLength: 40 }
          maxItems: 30
          description: Hard filter — output text containing any banned word triggers one regen, then `brand_kit_banned_word_in_output`.
        boilerplate:
          type: string
          maxLength: 1000
          description: Brand description paragraph for Studio Agent system context.
    BrandKitAudience:
      type: object
      required: [primary_persona, age_range, demographics, interests, pain_points]
      properties:
        primary_persona:
          type: string
          maxLength: 500
        age_range:
          type: string
          description: Free-text range (e.g. "25-44").
          maxLength: 20
        demographics:
          type: string
          maxLength: 500
        interests:
          type: array
          items: { type: string, maxLength: 60 }
          maxItems: 20
        pain_points:
          type: array
          items: { type: string, maxLength: 120 }
          maxItems: 10
    BrandKitPsychology:
      type: object
      required: [brand_archetype, emotional_drivers, aspirations, objections]
      properties:
        brand_archetype: { $ref: "#/components/schemas/BrandArchetype" }
        emotional_drivers:
          type: array
          items: { type: string, maxLength: 60 }
          maxItems: 8
        aspirations:
          type: string
          maxLength: 500
        objections:
          type: array
          items: { type: string, maxLength: 200 }
          maxItems: 8
    BrandKit:
      type: object
      required:
        - id
        - object
        - org_id
        - label
        - is_default
        - visual
        - copy
        - audience
        - psychology
        - created_at
        - updated_at
      properties:
        id:
          type: string
          example: bk_01HF9X2K7Z9Q3W6V5R4Y8M0NPB
        object:
          type: string
          enum: [brand_kit]
        org_id:
          type: string
          description: Owning account/org id.
        label:
          type: string
          maxLength: 80
        is_default:
          type: boolean
          description: Exactly one default per org allowed.
        visual: { $ref: "#/components/schemas/BrandKitVisual" }
        copy: { $ref: "#/components/schemas/BrandKitCopy" }
        audience: { $ref: "#/components/schemas/BrandKitAudience" }
        psychology: { $ref: "#/components/schemas/BrandKitPsychology" }
        archived_at:
          type: string
          format: date-time
          nullable: true
          description: Set when soft-deleted; cleared on restore. Hard-deleted by sweeper after 30 days.
        last_used_at:
          type: string
          format: date-time
          nullable: true
        jobs_run:
          type: integer
          description: Cumulative number of generations that referenced this kit.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    BrandKitSummary:
      type: object
      description: Subset of BrandKit returned in list endpoints.
      required: [id, object, label, is_default, archived_at, created_at, updated_at]
      properties:
        id: { type: string }
        object:
          type: string
          enum: [brand_kit]
        label: { type: string }
        is_default: { type: boolean }
        archived_at:
          type: string
          format: date-time
          nullable: true
        last_used_at:
          type: string
          format: date-time
          nullable: true
        jobs_run: { type: integer }
        brand_archetype: { $ref: "#/components/schemas/BrandArchetype" }
        thumbnail_url:
          type: string
          format: uri
          description: First reference image OR logo, whichever exists.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    BrandKitCreateRequest:
      type: object
      required: [label, visual, copy, audience, psychology]
      properties:
        label:
          type: string
          maxLength: 80
        is_default:
          type: boolean
          default: false
        visual: { $ref: "#/components/schemas/BrandKitVisual" }
        copy: { $ref: "#/components/schemas/BrandKitCopy" }
        audience: { $ref: "#/components/schemas/BrandKitAudience" }
        psychology: { $ref: "#/components/schemas/BrandKitPsychology" }
    BrandKitUpdateRequest:
      type: object
      description: Any subset of mutable fields. Server bumps `updated_at`.
      properties:
        label:
          type: string
          maxLength: 80
        is_default: { type: boolean }
        visual: { $ref: "#/components/schemas/BrandKitVisual" }
        copy: { $ref: "#/components/schemas/BrandKitCopy" }
        audience: { $ref: "#/components/schemas/BrandKitAudience" }
        psychology: { $ref: "#/components/schemas/BrandKitPsychology" }
    BrandKitWithCharge:
      allOf:
        - $ref: "#/components/schemas/BrandKit"
        - type: object
          required: [credits_charged]
          properties:
            credits_charged:
              type: integer
              description: Credits debited for this create/clone (0 if within included quota; 500 otherwise).
    BrandKitImportResponse:
      type: object
      required: [draft, credits_charged]
      properties:
        draft:
          allOf:
            - $ref: "#/components/schemas/BrandKitCreateRequest"
          description: |
            LLM-generated draft kit, NOT saved. Frontend pre-fills the wizard;
            the user reviews/edits, then POSTs to `/v1/brand-kits` to commit
            (charges 500 cr if over the included quota).
        credits_charged:
          type: integer
          description: LLM planning credits charged for this import (≤30 cr typical).
        source_warnings:
          type: array
          items: { type: string }
          description: Per-section warnings for fields the LLM could not infer with confidence.
    BrandKitListResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/BrandKitSummary" }
        included_quota:
          type: integer
          description: Brand kits included on the caller's plan (0 on Free/PAYG, 2 on Starter/Creator/Pro, 5 on Studio, 10 on Agency).
        active_count:
          type: integer
          description: Number of active (non-archived) kits.
        hard_cap:
          type: integer
          description: Hard cap per org (50).
