openapi: 3.1.0
info:
  title: WideCast.ai Public API
  version: 0.1.0
  summary: Turn plain-text scripts into AI-generated short-form videos.
  description: |
    WideCast.ai turns plain-text scripts (and soon ideas, audio, existing videos)
    into AI-generated short-form videos: scenes + voice + B-roll, ready for
    review in the scene editor.

    **Authentication**: every request needs an API key, passed as
    `Authorization: Bearer wc_live_*`. Generate keys in the dashboard
    (Setup Center → API Keys). Each call to `/v1/create_video` consumes
    credits from your account; usage (calls + credits) is tracked per key.

    ## Two input flows (`source`) × two completion gates (`output_type`)

    ### `source` — what kind of input you provide

    1. **`source: "text"`** (default) — you provide `script_text` (80-500
       words), used VERBATIM by the narrator. No AI rewriting.
    2. **`source: "idea"`** — you provide `idea_text` (5-1000 words, longer
       gets auto-truncated). The server writes a narration from the idea
       (AI), then continues the same scene-sourcing flow as `text`. You
       also pick `language`, `video_length` (short/normal), and
       `research_enabled`.
    3. **`source: "blog"`** — you provide `blog_text` (30-3000 words — an
       article to repurpose; longer auto-truncated). Identical pipeline to
       `idea` (same AI script-writer), just a longer input. Same optional
       `language` / `video_length` / `research_enabled`.

    `idea` and `blog` are **generative sources**: the server writes the script
    first (Phase 1), so they support `output_type: "text"` to stop at the
    editable script. Future generative sources (audio, video) follow the
    same shape.

    ### `output_type` — how far down the pipeline to run (pipeline depth)

    1. **`output_type: "text"`** — stop after the source→script-text phase.
       Returns a `review_url` (the UI auto-detects the data is narration-only
       and opens the Script Editor). **Only valid for generative sources**
       (`source != "text"`) — for `source: "text"` you already supplied the
       script, so `text` output would echo your input (HTTP 400).
    2. **`output_type: "scene"`** (default) — stop at "scenes ready for
       review". You get back a `review_url` to inspect / edit before deciding
       to render the final MP4. Optional follow-up: `POST /v1/export_video`.
    3. **`output_type: "video"`** — run end-to-end without manual review.
       `result.video_url` populated when the final MP4 is ready. Use this
       when you trust the generated scenes (programmatic / batch use).

    The two axes combine — every valid `source` × `output_type` pair works,
    except `source: "text"` + `output_type: "text"` (rejected — see above).

    ## Endpoints

    - **POST `/v1/create_video`** — submit `script_text`/`idea_text` + optional
      `output_type`. Returns a `gubo*` id and initial `status: "processing"`.
    - **POST `/v1/export_video`** — for `output_type: "scene"` videos that
      reached `scenes_ready_for_review`, kick the final-MP4 renderer.
      Idempotent — calling twice is a no-op.
    - **GET `/v1/status/{id}`** — poll until `status` is `completed` (or
      `failed`). The completion gate depends on the video's `output_type`:
        * text  → `stage: "script_ready_for_review"`, `result.review_url` set.
        * scene → `stage: "scenes_ready_for_review"`, `result.review_url` set.
        * video → `stage: "video_rendered"`, `result.video_url` AND
          `result.review_url` set.

    Polling cadence: start at 5s, exponential backoff (×1.5) up to 60s. The
    SDKs implement this automatically via `video.wait()`.

    ## Script bounds (LOCKED)

    `script_text` is used **verbatim** by the narrator — no rewriting. The
    bounds reflect what fits inside a short-form video:

    - **Minimum 80 words** (~20s of narration) — anything shorter renders
      a useless clip.
    - **Maximum 500 words** (~120s / 2 min of narration) — beyond this,
      render cost and pacing both suffer.

    ## Inline media in `script_text`

    Drop a **direct image or video URL** into the script next to the line it
    should illustrate — WideCast strips it from the narration and uses it as
    that scene's visual instead of auto-sourced B-roll. Direct file links only
    (image `.png/.jpg/.jpeg/.gif/.webp/.bmp/.avif/.svg`, video
    `.mp4/.webm/.mov/.m4v/.avi`); page links like a YouTube watch URL are not
    inlined (use `source="video_url"` for those). See the `script_text` field
    for details.

    ## Field-requirement convention (LOCKED)

    Every constraint on every field — validation bounds, enum values,
    required-vs-optional, defaults — appears in **four places**, kept in
    sync:

    1. **OpenAPI spec** — `description`, `minimum`/`maximum`, `enum`,
       `default`. Single source devs read first.
    2. **SDK constants + types** — named exports in `widecast` (Python) and
       `@widecast/sdk` (TypeScript), so callers can refer to them
       programmatically without re-typing the literal.
    3. **Markdown docs** — `Field reference` table in each endpoint page.
    4. **Server enforcement** — rejected requests must surface a LOCKED
       `error.code` (e.g. `script_too_short`, `invalid_output_type`).

    The SDK parity test (`test_script_bounds_constants_locked` and
    equivalents) imports each SDK constant and asserts the exact numeric
    value — changing one surface without the others breaks the test. New
    endpoints MUST follow this same pattern.

    ## Conventions (locked — changing = breaking)

    - Snake_case JSON fields
    - **ID format**: `gubo<alphanumeric>` (~20 chars). The server's new-script
      detection checks for the literal `"gubo"` substring, so SDKs/clients MUST
      not strip it. Public id == internal `topic_id` (1:1, no mapping).
    - API key prefixes: `wc_live_*`
    - Object marker: every resource has `"object": "<type>"`
    - Status enum: `pending | processing | completed | failed`
      (`pending` ONLY covers the brief race window between create + worker
      writing the topic row; once the row exists, status is `processing` —
      the abroll worker doesn't increment `step` until the end)
    - Error envelope: `{ "error": { "type", "code", "message", "param?",
      "doc_url", "request_id" } }`
    - Error codes (v0.1.0): `account_expired | credit_exhausted |
      render_failed | unknown_error | scenes_not_ready | export_failed |
      script_too_short | script_too_long | invalid_output_type |
      invalid_source | idea_too_short | missing_idea_text |
      blog_too_short | missing_blog_text |
      missing_video_url | missing_audio_url | missing_media_file |
      unsupported_media_url | media_too_long | file_too_large |
      missing_api_key | invalid_api_key |
      invalid_language | invalid_video_length | invalid_research_enabled |
      invalid_faceless`
    - Headers (every response): `X-Request-Id`, `X-RateLimit-*`,
      `X-WideCast-Version`
    - Timestamps: ISO 8601 UTC strings (e.g. `2026-05-19T17:30:00Z`)
    - Pagination (future): cursor-based — see `ListEnvelope` schema
  contact:
    name: WideCast Support
    url: https://docs.widecast.ai
    email: support@widecast.ai
  license:
    name: Apache-2.0
    url: https://www.apache.org/licenses/LICENSE-2.0

servers:
  - url: https://gubo.ai/app/dashboard2
    description: Dev server — Bearer wc_live_* API key
  - url: https://api.widecast.ai
    description: Production (planned)

tags:
  - name: Videos
    description: Create videos and poll their status.
  - name: Spec
    description: OpenAPI spec served from the same host.
  - name: Webhooks
    description: |
      Documented OUT-of-band — WideCast does NOT define a webhook endpoint on
      its own server; the client supplies a `callback_url` in `POST /v1/create_video`,
      and WideCast POSTs events to that URL. See the WebhookEvent schema below
      for the payload + the `WebhookEvent.event_type` enum for fire-points.

security:
  - bearerAuth: []   # Every endpoint requires an API key: Authorization: Bearer wc_live_*
                     # (server gates enforcement behind WIDECAST_AUTH_ENABLED for safe rollout)

paths:
  /v1/create_video:
    post:
      tags: [Videos]
      operationId: createVideo
      summary: Create a video from a plain-text script.
      description: |
        Create a video from either a plain-text script (`source: "text"`) or
        a short idea description (`source: "idea"`). The server wraps the
        same background workers that power the Script Editor + Idea-to-Video
        flows in the Gubo UI.

        - Returns immediately with `status: "processing"` (HTTP 202). Poll
          `GET /v1/status/{id}` until terminal.
        - If `wait_for_render: true`, blocks up to **60 seconds**. If render
          completes within the window, returns `status: "completed"` (HTTP 200).
          Otherwise returns the in-flight state and the client polls.

        ### source

        - `"text"` (default — backward-compat): provide `script_text`
          (80-500 words, used VERBATIM by the narrator). Worker:
          `request_video_script_from_script`.
        - `"idea"`: provide `idea_text` (5-1000 words; >1000 auto-truncated)
          plus optional `language` / `video_length` / `research_enabled`.
          Worker chain: `ai_enhance_script` (idea → narration) →
          `request_video_script_from_script` (narration → scenes).

        ### output_type

        - `"scene"` (default): pipeline stops at scenes-ready-for-review.
          `result.review_url` populated on completion. You can then call
          `POST /v1/export_video` to render the final MP4.
        - `"video"`: pipeline auto-chains into the renderer once scenes are
          ready. `result.video_url` populated on completion. No manual
          `/v1/export_video` call needed.

        ### Bounds (LOCKED — see SDK constants)

        | Field | When required | Min words | Max words | Over-max behavior |
        |---|---|---|---|---|
        | `script_text` | source=text | 80 (`SCRIPT_MIN_WORDS`) | 500 (`SCRIPT_MAX_WORDS`) | reject `script_too_long` |
        | `idea_text` | source=idea | 5 (`IDEA_MIN_WORDS`) | 1000 (`IDEA_MAX_WORDS`) | auto-truncate, surfaced in `details.input_truncated_from` |
        | `blog_text` | source=blog | 30 (`BLOG_MIN_WORDS`) | 3000 (`BLOG_MAX_WORDS`) | auto-truncate, surfaced in `details.input_truncated_from` |

        SDKs export these as named constants — refer to those instead of
        hard-coding the numbers. Word count = whitespace split, works for
        English + Vietnamese.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateVideoRequest'
            examples:
              text_scene_en:
                summary: source=text, output_type=scene — paste narration, review scenes
                value:
                  source: text
                  script_text: "Want to make a video in 60 seconds? Here's how — and why it actually works. First, draft your script in bullet points. Don't overthink it. Three to five beats is plenty: a hook that grabs attention, a body that delivers one clear idea, and a call to action that tells the viewer exactly what to do next. Second, paste it into WideCast and pick an aspect ratio. Vertical for TikTok and Reels, square for feed posts. Third, hit render and grab a coffee. The AI does the work. You come back to a video that's ready to publish. Try it free at widecast.ai today."
              text_video_en:
                summary: source=text, output_type=video — paste narration, get MP4
                value:
                  source: text
                  script_text: "California parents should encourage their teens to get a driver's license at 16. Driving builds independence, responsibility, and confidence — three things every teenager needs more of, and three things you can't teach from a couch. Behind the wheel is where they learn to make decisions in real time, manage risk, and own the consequences of their own choices. The longer you delay, the harder it gets. Don't make your kid that twenty-two-year-old learning under pressure. Let them get the license now."
                  output_type: video
              idea_scene_en:
                summary: source=idea, output_type=scene — describe an idea, review scenes
                value:
                  source: idea
                  idea_text: "Why teens should get their driver's license at 16, not 18 — it's about independence and stake-low practice."
                  language: English
                  video_length: short
              idea_video_vi:
                summary: source=idea, output_type=video, Vietnamese, normal length — full MP4
                value:
                  source: idea
                  idea_text: "Tại sao phụ huynh nên cho con học lái xe ngay khi đủ 16 tuổi — lợi ích về tính độc lập, trách nhiệm, và kỹ năng ra quyết định trong thời gian thực."
                  language: Vietnamese
                  video_length: normal
                  research_enabled: true
                  output_type: video
              video_url_scene:
                summary: source=video_url, output_type=scene — Auto-Edit a YouTube/TikTok video
                value:
                  source: video_url
                  video_url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
                  output_type: scene
              video_url_text_remake:
                summary: source=video_url, output_type=text — Remake (extract transcript only)
                value:
                  source: video_url
                  video_url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
                  output_type: text
              audio_url_video:
                summary: source=audio_url, output_type=video — Audio-to-Video, end-to-end MP4
                value:
                  source: audio_url
                  audio_url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
                  output_type: video
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/CreateVideoRequest'
            encoding:
              video_file:
                contentType: video/*
              audio_file:
                contentType: audio/*
            examples:
              video_file_scene:
                summary: source=video_file, output_type=scene — upload a video file (Auto-Edit)
                value:
                  source: video_file
                  output_type: scene
              audio_file_video:
                summary: source=audio_file, output_type=video — upload an audio file (Audio-to-Video)
                value:
                  source: audio_file
                  output_type: video
      responses:
        '200':
          description: Render finished within the `wait_for_render` window. `status == "completed"`.
          headers: &commonHeaders
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
            X-WideCast-Version: { $ref: '#/components/headers/XWideCastVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Status' }
        '202':
          description: Video queued; poll `/v1/status/{id}`.
          headers: *commonHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Status' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'

  /v1/export_video:
    post:
      tags: [Videos]
      operationId: exportVideo
      summary: Kick the final-MP4 renderer for an already-created video.
      description: |
        Pairs with `output_type: "scene"` on `/v1/create_video`. After the
        scenes-ready callback (or manual review via `review_url`), call this
        to enqueue the final render.

        Idempotent — calling twice is a no-op. Returns the current
        `/v1/status` snapshot.

        Preconditions:
        - The video must have reached `scenes_ready_for_review` (HTTP 409 with
          `error.code = "scenes_not_ready"` otherwise).

        Post-effects:
        - The video's effective `output_type` is promoted to `"video"`.
        - The completion gate becomes `result.video_url` non-empty.
        - The `video.completed` webhook (if `callback_url` is set) re-arms
          and fires when the MP4 is rendered.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportVideoRequest'
            examples:
              minimal:
                summary: Kick render for a previously-created video
                value:
                  id: gubo7c0d4f8a9b1e2d3f
      responses:
        '200':
          description: Idempotent no-op — the queue row already exists. Returns the current `/v1/status` snapshot.
          headers: *commonHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Status' }
        '202':
          description: Render queued. Poll `/v1/status/{id}` until `status == "completed"` with `result.video_url`.
          headers: *commonHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Status' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/PreconditionFailed'
        '500':
          $ref: '#/components/responses/ServerError'

  /v1/status/{id}:
    get:
      tags: [Videos]
      operationId: getStatus
      summary: Poll the current state of a video.
      description: |
        Universal status endpoint. v0.1.0 supports only the `gubo*` ids
        returned by `/v1/create_video`; future flows will reuse the same
        endpoint with new id prefixes.

        Recommended polling cadence: **5s initial, ×1.5 exponential backoff,
        cap at 60s**. The SDKs' `video.wait()` implements this automatically.

        When `status == "completed"`, `result.review_url` is the URL where the
        user reviews the rendered scenes + audio. `result.script` is the full
        rendered video script JSON (with `segments` populated).
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: '^gubo[a-zA-Z0-9]{12,32}$'
            example: gubo7c0d4f8a9b1e2d3f
      responses:
        '200':
          description: Current state.
          headers: *commonHeaders
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Status' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /openapi.json:
    get:
      tags: [Spec]
      operationId: getOpenapiJson
      summary: Fetch this OpenAPI spec as JSON.
      responses:
        '200':
          description: OpenAPI 3.1 spec.
          content:
            application/json:
              schema: { type: object }

  /openapi.yaml:
    get:
      tags: [Spec]
      operationId: getOpenapiYaml
      summary: Fetch this OpenAPI spec as YAML.
      responses:
        '200':
          description: OpenAPI 3.1 spec.
          content:
            application/yaml:
              schema: { type: string }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: WideCast API Key (wc_live_*)

  headers:
    XRequestId:
      schema: { type: string, example: req_c0a8f9e8d7b6a5f4e3d2c1b0 }
      description: Unique ID for this request — include in any support ticket.
    XRateLimitLimit:
      schema: { type: integer, example: 1000 }
    XRateLimitRemaining:
      schema: { type: integer, example: 999 }
    XRateLimitReset:
      schema: { type: integer, example: 1747680000 }
    XWideCastVersion:
      schema: { type: string, example: "0.1.0" }

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
        example: 1d4f9c8a-9b71-4c5a-93b3-7c8e4d2e8f10
      description: |
        Client-generated UUID. Two requests with the same key within 24h return
        the SAME video and do NOT create a new one. **Read but not enforced
        in pilot.**

  schemas:
    CreateVideoRequest:
      type: object
      description: |
        Conditional shape based on `source`:
          * source="text" (default) → `script_text` required.
          * source="idea" → `idea_text` required; `language`, `video_length`,
            `research_enabled` optional with defaults.
        The other text field is ignored when not matching `source`.
      required: [source]
      properties:
        source:
          type: string
          enum: [text, idea, blog, video_url, video_file, audio_url, audio_file]
          default: text
          description: |
            Discriminates the input flow:
              * `text` (default — backward-compat): provide `script_text`
                used VERBATIM by the narrator.
              * `idea`: provide `idea_text`; server writes a narration from
                the idea (AI) then continues into the same scene-sourcing
                pipeline as `text`.
              * `blog`: provide `blog_text` (an article to repurpose);
                identical pipeline to `idea`, just a longer input.
              * `video_url` / `audio_url`: provide `video_url` / `audio_url`
                (a YouTube / TikTok / Facebook URL). Media-ingest (A49) — the
                script already lives in the media. The footage becomes the
                B-roll (video) / the audio becomes the narration (audio).
              * `video_file` / `audio_file`: upload the media file. Send the
                request as **multipart/form-data** with the file in the
                `video_file` / `audio_file` part (mirrors the UI upload).
            `idea` and `blog` are generative sources — they support
            `output_type: "text"` (stop at the editable script). Media sources
            support `output_type: "text"` too = Remake (A50): extract the
            transcript only, no footage/scene build.
          x-widecast-enum-locked: true
        script_text:
          type: string
          description: |
            **Required when `source="text"`.** Plain text script — pasted
            narration. Used VERBATIM by the narrator (no AI rewriting).
            Bounds are LOCKED:
              * minimum 80 words (~20s of narration)
              * maximum 500 words (~120s / 2 min of narration)

            Word count = whitespace split (`len(text.split())`) — works for
            English + Vietnamese. Outside the bounds → HTTP 400 with
            `error.code` = `script_too_short` / `script_too_long`.

            **Inline media (use your own images/videos).** You can drop a
            direct image or video URL straight into the script, right after
            (or before) the sentence it should illustrate. WideCast (1) strips
            the URL from the spoken narration and (2) uses that asset as the
            visual for the matching scene, instead of auto-sourced B-roll.
            Supported = direct file links ending in an image extension
            (`.png .jpg .jpeg .gif .webp .bmp .avif .svg`) or video extension
            (`.mp4 .webm .mov .m4v .avi`), optionally with a `?query`. Page
            links (e.g. `https://youtube.com/watch?...`) are NOT used as inline
            assets — to turn a whole YouTube/TikTok clip into a video use
            `source="video_url"` instead. Keep a few descriptive words next to
            the URL so it anchors to the right scene; multiple URLs are
            supported; scenes without a URL still get automatic B-roll.
          x-widecast-min-words: 80
          x-widecast-max-words: 500
          x-widecast-required-when: 'source == "text"'
          example: "California parents should encourage their teens to get a driver's license at 16. Driving builds independence, responsibility, and confidence — three things every teenager needs more of, and three things you can't teach from a couch..."
        idea_text:
          type: string
          description: |
            **Required when `source="idea"`.** Short description of what you
            want the video about. The server writes a narration from this
            (AI) and continues into the scene-sourcing pipeline. Bounds:
              * minimum 5 words — `idea_too_short` if shorter.
              * maximum 1000 words — longer is auto-truncated at a whitespace
                boundary (NOT rejected). Original word count surfaces in
                `details.input_truncated_from` on `/v1/status`.

            Word count = whitespace split.
          x-widecast-min-words: 5
          x-widecast-max-words: 1000
          x-widecast-overflow-policy: truncate
          x-widecast-required-when: 'source == "idea"'
          example: "Why teens should get their driver's license at 16, not 18 — independence, responsibility, and stake-low practice."
        blog_text:
          type: string
          description: |
            **Required when `source="blog"`.** A blog post or article to
            repurpose into a video. The server condenses it into a narration
            (same AI script-writer as `idea`) then continues into the
            scene-sourcing pipeline. Bounds:
              * minimum 30 words — `blog_too_short` if shorter (use
                `source=idea` for a one-liner brief instead).
              * maximum 3000 words — longer is auto-truncated at a whitespace
                boundary (NOT rejected). Original word count surfaces in
                `details.input_truncated_from` on `/v1/status`.

            Word count = whitespace split.
          x-widecast-min-words: 30
          x-widecast-max-words: 3000
          x-widecast-overflow-policy: truncate
          x-widecast-required-when: 'source == "blog"'
          example: "Every hurricane season, thousands of homeowners discover the same painful fact: their homeowner's insurance does not cover flood damage. Standard policies exclude rising-water damage entirely..."
        video_url:
          type: string
          format: uri
          description: |
            **Required when `source="video_url"`.** A YouTube / TikTok /
            Facebook video URL. The server downloads it, then the footage
            becomes the B-roll (Auto-Edit). Non-allowed hosts → HTTP 400
            `unsupported_media_url`; media over 2 minutes → `media_too_long`.
          x-widecast-required-when: 'source == "video_url"'
          x-widecast-max-duration-seconds: 120
          example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
        audio_url:
          type: string
          format: uri
          description: |
            **Required when `source="audio_url"`.** A YouTube / TikTok /
            Facebook URL whose audio becomes the narration (Audio-to-Video).
            Same host allow-list + 2-minute cap as `video_url`.
          x-widecast-required-when: 'source == "audio_url"'
          x-widecast-max-duration-seconds: 120
          example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
        video_file:
          type: string
          format: binary
          description: |
            **Required when `source="video_file"`.** The video file, sent as
            multipart/form-data. Caps: **≤ 100 MB** (`file_too_large`) and
            **≤ 2 minutes** (`media_too_long`). Only present in the multipart
            request variant.
          x-widecast-required-when: 'source == "video_file"'
          x-widecast-max-bytes: 104857600
          x-widecast-max-duration-seconds: 120
        audio_file:
          type: string
          format: binary
          description: |
            **Required when `source="audio_file"`.** The audio file, sent as
            multipart/form-data. Caps: **≤ 100 MB** (`file_too_large`) and
            **≤ 2 minutes** (`media_too_long`). Only present in the multipart
            request variant.
          x-widecast-required-when: 'source == "audio_file"'
          x-widecast-max-bytes: 104857600
          x-widecast-max-duration-seconds: 120
        language:
          type: string
          enum: [English, Vietnamese]
          default: English
          description: |
            **Used when `source="idea"`** (ignored for source=text).
            Narration language. v0.1.0 enum locked to English + Vietnamese.
            Expanding requires a parity-test bump per A38.
          x-widecast-enum-locked: true
        video_length:
          type: string
          enum: [short, normal]
          default: short
          description: |
            **Used when `source="idea"`** (ignored for source=text).
            Target video length. Maps to legacy `content_type` 9 / 10.
              * `short` — ~90 seconds of narration (content_type 9).
              * `normal` — longer-form, capped at ~3 minutes total
                (content_type 10).
          x-widecast-enum-locked: true
        research_enabled:
          type: boolean
          default: true
          description: |
            **Used when `source="idea"`** (ignored for source=text).
            If true, the AI runs research / fact-check during narration
            generation. Default true (matches the UI's default-on behavior).
            Disable only if you know your idea is self-contained and
            doesn't need external facts.
        output_type:
          type: string
          enum: [text, scene, video]
          default: scene
          description: |
            How far down the pipeline to run (pipeline depth):
              * `text` — stops after the source→script-text phase.
                `result.review_url` populated; UI opens the Script Editor.
                ONLY valid for generative sources (source != "text").
                source="text" + output_type="text" → HTTP 400
                `invalid_output_type` (you already supplied the script).
              * `scene` (default) — stops at scenes-ready-for-review.
                `result.review_url` populated on completion. Caller can then
                inspect / edit and call `POST /v1/export_video` to render the
                MP4 — or stop there.
              * `video` — auto-chains into the renderer once scenes are ready.
                `result.video_url` AND `result.review_url` populated on
                completion. Use when you trust generated scenes.
          x-widecast-enum-locked: true
          x-widecast-requires-generative-source: text
        faceless:
          type: boolean
          default: false
          description: |
            **Used when `output_type` is `scene` or `video`** (sources
            text / idea / blog). When `true`, EVERY scene is rendered as
            B-roll (stock video / AI image) with NO narrator A-roll anywhere —
            a "faceless" video. When `false` (default), scenes mix narrator
            A-roll + B-roll as usual. Rejected with HTTP 400 `invalid_faceless`
            when `output_type="text"` (no scenes are produced) or for media
            sources (`video_*` / `audio_*` already supply their own visuals).
          x-widecast-applies-when: 'output_type in ("scene","video") and source in ("text","idea","blog")'
        wait_for_render:
          type: boolean
          default: false
          description: |
            If true, block up to 60s waiting for completion. If the deadline hits
            before completion, return the in-flight state and let the client poll.
        callback_url:
          type: string
          format: uri
          nullable: true
          description: |
            HTTPS URL that will receive a POST when the video completes or fails.
            **Accepted but not delivered in pilot — see ROADMAP Phase 1 webhooks.**
        metadata:
          type: object
          additionalProperties: true
          description: Arbitrary key-value pairs stored alongside the video and echoed back on status. Max 16 keys, 500 chars each.

    ExportVideoRequest:
      type: object
      required: [id]
      properties:
        id:
          type: string
          pattern: '^gubo[a-zA-Z0-9]{12,32}$'
          description: |
            `topic_id` returned by `/v1/create_video`. The video must have
            reached `scenes_ready_for_review` (HTTP 409 otherwise).
          example: gubo7c0d4f8a9b1e2d3f

    Status:
      type: object
      description: Unified async-task status envelope returned by `create_video` and `get_status`.
      required: [object, id, topic_id, type, status, stage, progress, details, result, error, metadata, usage, links, meta]
      properties:
        object:
          type: string
          const: status
        id:
          type: string
          pattern: '^gubo[a-zA-Z0-9]{12,32}$'
          example: gubo7c0d4f8a9b1e2d3f
        topic_id:
          type: string
          pattern: '^gubo[a-zA-Z0-9]{12,32}$'
          example: gubo7c0d4f8a9b1e2d3f
          description: |
            Mirrors `id` 1:1. Exposed separately because the legacy `/checkProgress`
            response and the scene-editor URL (`?topic_id=`) use this name —
            clients can pick whichever field name reads more clearly.
        type:
          type: string
          description: Resource type — currently always "video".
          enum: [video]
        status:
          type: string
          enum: [pending, processing, completed, failed]
          description: |
            * `pending` — id issued, worker hasn't written the topic row yet
              (race window, usually <2s). The server caches issued ids in memory
              for 10 minutes so polling clients never see 404 during this window.
            * `processing` — topic row exists, worker is on it. Stays in this
              state for the ENTIRE run (the abroll worker doesn't increment
              `step` until the final flip — don't expect granular progress).
            * `completed` — scenes ready for review (`result.review_url` populated).
            * `failed` — unrecoverable error (`error.code` + `error.message` populated).
        stage:
          type: string
          description: Fine-grained internal stage (informational, may evolve — don't gate logic on it).
          example: "step_0"
        progress:
          type: number
          minimum: 0.0
          maximum: 1.0
          description: |
            Honest progress estimate (not step-based, since the worker stays at
            step=0 for most of the run):
              * 0.05 — id issued, no topic row yet (pending / race)
              * 0.30 — topic row exists, no rendered script yet (worker starting)
              * 0.80 — script with segments present, finalisation pending
              * 1.00 — completed
        details:
          oneOf:
            - { type: "null" }
            - $ref: '#/components/schemas/ProcessingDetails'
          description: |
            Fine-grained worker state surfaced from the legacy topic / queue row.
            `null` during the pending race window (no topic row yet). For abroll
            (current pilot endpoint), most fields stay at default until the
            worker flips to step=5+Completed at the end; the render pipeline
            (future endpoint) will populate these progressively.
        result:
          oneOf:
            - { type: "null" }
            - $ref: '#/components/schemas/StatusResult'
          description: Populated only when `status == "completed"`.
        error:
          oneOf:
            - { type: "null" }
            - $ref: '#/components/schemas/StatusError'
          description: Populated only when `status == "failed"`.
        callback_url:
          type: string
          format: uri
          nullable: true
        metadata:
          type: object
          additionalProperties: true
        usage:
          type: object
          nullable: true
          description: |
            Cost & credit info. **`null` in pilot** — populated once billing
            lands (ROADMAP Phase 1).
        links:
          type: object
          properties:
            self:
              type: string
              example: "/v1/status/gubo7c0d4f8a9b1e2d3f"
        meta:
          type: object
          properties:
            request_id: { type: string }
            widecast_version: { type: string }

    StatusResult:
      type: object
      description: |
        Minimal user-facing output. The v0.1.0 pilot returns `review_url` for
        every completed video (auth-adaptive: read-only embeddable player when
        not signed in, full editor when signed in), and additionally `video_url`
        when the video was created with `output_type: "video"` (or when
        `/v1/export_video` finished). The rendered video script (with
        segments) is intentionally NOT inlined to keep status responses cheap.
        A future `GET /v1/videos/{id}/script` endpoint will serve the full
        script explicitly when needed.
      required: [review_url]
      properties:
        review_url:
          type: string
          format: uri
          description: |
            URL where the end user views / edits the rendered scenes + audio.
            Append **`&readonly=true`** to get a public, **read-only player**
            (no sign-in) that does NOT block iframe embedding — use that variant
            as an `<iframe>` src to show the result inline. The plain URL opens
            the full editor for a signed-in user. Keyed by `topic_id`
            (+ `company_id`). Default template:
            `https://gubo.ai/#scene_editor?topic_id=<id>` (overridable
            server-side via `WIDECAST_REVIEW_URL_TEMPLATE` env); embeddable
            variant: `https://gubo.ai/#scene_editor?topic_id=<id>&readonly=true`.
          example: https://gubo.ai/#scene_editor?topic_id=gubo7c0d4f8a9b1e2d3f
        video_url:
          type: string
          format: uri
          description: |
            Direct URL to the rendered MP4. Populated only for videos created
            with `output_type: "video"` or exported via `POST /v1/export_video`.
          example: https://gubo.ai/exports/huubinhnguyen/gubo7c0d4f8a9b1e2d3f/final.mp4

    ProcessingDetails:
      type: object
      description: |
        Raw worker state for fine-grained progress visibility. Field names
        mirror the legacy `topics_table` (or `video_queue_table`) schema.

        ⚠ **DELIBERATE NAME CLASH**: `details.status` is a free-form legacy
        string ("Completed", "Error", "Avatar videos downloaded", …),
        DISTINCT from the top-level `status` enum. Gate logic on the
        top-level value; treat `details.status` as a human/debug hint.
      required: [step, status, notes]
      properties:
        step:
          type: integer
          minimum: 0
          maximum: 5
          description: Legacy `step` value (0 during abroll, 5 at completion). Render pipeline cycles 0→2→5.
        status:
          type: string
          description: Free-form legacy state string. NOT our top-level status enum.
          example: ""
        notes:
          type: string
          description: Free-form note from the worker (errors, assignment messages, etc.).
          example: ""
        updated_at:
          type: string
          format: date-time
          description: When the worker last touched the row (ISO 8601 UTC).
          example: "2026-05-20T12:34:56Z"
        input_truncated_from:
          type: integer
          description: |
            Original word count when the server auto-truncated a generative
            input to its max (idea_text → 1000, blog_text → 3000). Only
            present when the input exceeded the bound. Lets clients show
            "your 4200-word article was trimmed to 3000 words" UX hints.
          example: 4200

    StatusError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum:
            - account_expired
            - credit_exhausted
            - render_failed
            - unknown_error
            - scenes_not_ready
            - export_failed
            - script_too_short
            - script_too_long
            - invalid_output_type
            - invalid_source
            - idea_too_short
            - missing_idea_text
            - blog_too_short
            - missing_blog_text
            - missing_video_url
            - missing_audio_url
            - missing_media_file
            - unsupported_media_url
            - media_too_long
            - file_too_large
            - missing_api_key
            - invalid_api_key
            - invalid_language
            - invalid_video_length
            - invalid_research_enabled
        message:
          type: string

    ListEnvelope:
      type: object
      description: |
        **Pagination convention** for every future list endpoint.
        Cursor-based — no list endpoint in v0.1.0 yet; this schema is reserved.
      required: [object, data, has_more]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { type: object }
        has_more: { type: boolean }
        next_cursor:
          type: string
          nullable: true

    WebhookEvent:
      type: object
      description: |
        The payload WideCast POSTs to your `callback_url`. Headers:
          * `User-Agent: WideCast-Webhook/<version>`
          * `X-WideCast-Event: <event_id>`
          * `X-WideCast-Event-Type: <event_type>`
          * `X-WideCast-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>` — verify against `WIDECAST_WEBHOOK_SECRET`
          * `X-WideCast-Delivery: <uuid>` — unique per delivery attempt
          * `X-WideCast-Attempt: <n>` — 1-indexed retry counter

        Retry: 10 attempts spread across 24h (offsets 0s/1m/5m/15m/30m/1h/3h/6h/12h/24h).
        Acknowledge with HTTP 2xx within 30s; non-2xx triggers retry until
        success, abandon (4xx), exhaustion (10 attempts), or 24h timeout.
      required: [event_id, event_type, created_at, api_version, data]
      properties:
        event_id:
          type: string
          example: evt_8d4f9c8a9b714c5a93b37c8e
          description: Unique per event — dedupe on this in your handler.
        event_type:
          type: string
          enum: [video.processing, video.completed, video.failed]
          description: |
            * `video.processing` — fires once, immediately after `POST /v1/create_video` (server has accepted, worker spawned).
            * `video.completed` — fires once, when status first reaches `completed`. `data.result.review_url` populated.
            * `video.failed` — fires once, when status first reaches `failed`. `data.error.{code,message}` populated.
        created_at:
          type: string
          format: date-time
        api_version:
          type: string
          example: "0.1.0"
        data:
          $ref: '#/components/schemas/Status'

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message, request_id, doc_url]
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - precondition_failed
                - rate_limit_error
                - api_error
            code: { type: string, example: missing_field }
            message: { type: string }
            param: { type: string, nullable: true }
            doc_url: { type: string, format: uri }
            request_id: { type: string }

  responses:
    BadRequest:
      description: Invalid request — see `error.code`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: |
        Missing, malformed, or revoked API key. Send a valid key as
        `Authorization: Bearer wc_live_...`. `error.code` is `missing_api_key`
        (no key) or `invalid_api_key` (malformed / unknown / revoked);
        `error.type` is `authentication_error`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PreconditionFailed:
      description: |
        The request is well-formed but the resource is not in the expected
        state — e.g. trying to export a video whose scenes are not yet
        ready (`error.code = "scenes_not_ready"`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServerError:
      description: Server-side failure. Include `meta.request_id` when reporting.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
