openapi: 3.0.3
info:
  title: 星河智能视频生成平台 · 供应商 API
  version: "1.3.9"
  description: |
    面向乙方（供应商）提供的视频生成服务接口契约。所有接口均需携带 `Authorization: Bearer <api_key>`。

    ## 星河字面（文字 PV）CD 模板 `xinghe-cd-001`（2026-10-24 新增，图文详版见 api-docs.html §18）
    - **能力**：给「歌词 + 原唱音频」，出歌词同步的**文字 PV 成片**；由**服务端直接编码**（不进渲染机队列），30s 成片实测约 2 分钟。
    - **模板**：`template_id=xinghe-cd-001`；`category=CD`；`status=enabled`；画幅 **16:9**，默认 **1080P**（`resolution=1080P`）。
    - **素材**：`material_spec.required` = `line_lyrics`（`.lrc`/`.txt`，≤1MB）+ `original_vocal`（`mp3`/`wav`/`flac`/`m4a`，≤50MB）；
      可选 `album_images`（≤12 张）、`cover`。歌词也可改用创建任务的 `song_info.lyrics` 内嵌传（二选一即可）。
    - **歌词口径（与下方「字幕（歌词）」规范不同，务必区分）**：本模板由**星河字面引擎按歌曲节拍自动排轴**，
      **LRC 时间戳不参与对时**（会被剥离，保留无害）；只需保证**文本与断行正确（一行一句）**。
      需要逐句精确对时/逐字扫色的请用 `CD-2024-005`（星光1号）。
    - **输出**：`output_resolution` 必须命中模板 `supported_resolutions`（否则 `4008`）：
      1280×720 / 1920×1080 / 2560×1440 / 3840×2160，帧率固定 **30fps**。
      成片 **H.264(avc1) + AAC**（引擎原生 Opus 已自动转 AAC，兼容 iOS/Safari/微信），MP4；**时长 ≈ 音频时长**。
    - **状态**：缺歌词 → `awaiting_materials` + `error_code=MISSING_LYRICS`（补料或重新提交后自动回队，无需新建任务）；
      编码失败自动重试 2 次，第 3 次置 `failed`（`error_code=XINGHE_RENDER_FAILED`）。
    - **同版修复模板列表接口**：`status=all` 返回全部（此前恒返回空列表）；`category` 大小写不敏感（`cd`/`CD` 等价）；
      `sample_url`/`cover_url` 对对象存储 key 现签（此前拼成 `https://<域名>/oss://...` 死链）。

    ## 字幕（歌词）· 字体 · 转场 提交规范（2026-10-22 定版，图文详版见 api-docs.html §17）
    - **歌词**：素材上传 `line_lyrics`（`.lrc`，标准 LRC 或 `<mm:ss.xx>` 增强逐字）/ `word_lyrics`（逐字 JSON `word/start_s/end_s`），
      或创建任务时 `song_info.lyrics`（纯文本，无时间轴，平台落成 `lyrics.txt`）。
      同包并存时优先级：`word_lyrics` > `line_lyrics` > 文件名含 `lrc` 的其它文件 > `lyrics.txt`（含 `song_info.lyrics`）。
      **时间戳一律从音频第 0 秒起算**：封面（CD 5s）/ 海报镜（MV `shots[0].durationMs`）偏移由平台自动追加，**甲方禁止自己加**。
      LRC 须覆盖到音频结束；每句显示到下一句开始，**最后一句按自身时长结束（≈每字 0.38s），平台不补句、不延长到音频/片尾**，
      空窗段即无歌词（不阻断出片）。
      编码 UTF-8（可带 BOM），GB18030 自动兜底，**勿用 UTF-16**。
    - **字体**：**字体随歌词，不单独指定**（2026-10-22 定版：**不做任务级字体指定，无字段、无预留草案**）。
      字体/字号/配色由渲染这份歌词的**歌词样式整体**决定：CD（星光1号）卡拉OK字幕固定 `Microsoft YaHei` 60px（设计分辨率 1920×1080）、
      未唱 `#FFD878` / 已唱 `#FFFFFF`、黑描边 3px + 阴影、粗体、字距 5px、底部居中（左右 55px / 下 48px）；
      MV 由所选歌词插件决定（平台字体库 65 款，见 fonts-catalog.html，供插件/模板使用）。
      要换字体只能**换歌词插件（MV）/ 换模板（CD）**；误传字体字段会被忽略（不报错、不告警）。
    - **转场**：优先级由高到低 —— ① MV 逐镜 `mv-shots.json → shots[i].transition = {type,duration}`
      ② 包级 `manifest.transitions.per_shot[k]` ③ 包级 `manifest.transitions.default` ④ 转场插件/后台默认 ⑤ 兜底 `fade` 0.5s。
      `per_shot[k]` 中 **k 从 1 起有效**（k = 目标镜/图序号，`k=0` 无前镜忽略）。
      `duration` 单位秒、夹取 **0.2–1.0s**；`cut`/`none` 或 `duration<=0` = 硬切；未知 `type` 该处忽略并回落，不阻断出片。
      **能力差异：MV（ffmpeg 后端）49 种 xfade 类型全量生效；CD（kdenlive/melt 后端）只生效「时长 / 是否硬切」，视觉统一为交叉溶解。**

    ## 海报镜尾帧口径修正（2026-10-19，**此前口径作废，按本版重新对接**）
    - **海报镜（`segmentType: "poster"`）与普通分镜同权**：`imageUrl`（首帧）+ `endImageUrl`（尾帧）+ `videoPrompt` 三者**全部必填**。
    - **缺尾帧一律打回**：解析阶段即失败，任务转 `awaiting_materials`、`error_code=MATERIAL_MISSING_END_FRAME`；
      **平台不做任何自动补齐/兜底** —— 不会用包内海报图代替，也不会用首帧「首尾同图」代替。
    - **作废的旧口径**：「海报为开场独立镜头、不参与首尾帧衔接」「海报尾帧不下发」（v3.13 文档 §15.4.2）
      —— 该表述导致甲方不传尾帧，海报画面文字缺失（T504/505/510/511）。
    - **尾帧上的文字由生成模型依据 `videoPrompt` 生成**：需要什么字就写在提示词里（内容 / 字形 / 字号 / 版式），
      尾帧图只作构图与收束参照；**不要**写「文字淡入 / 渐显 / 从无字显现」等动效描述（会诱导模型拼字造字）。
    - **平台自动插入**的海报镜仍为**首尾同图**（首帧 = 尾帧 = 海报图），该设计不变。
    - 自查脚本 `docs/validate-mv-shots.mjs` 已新增闸门 ⑤（每镜必须有尾帧）与 ⑧（海报镜首尾帧 + 提示词），本地即可提前发现。

    ## 交付规格变更（2026-10-06，经双方确认）
    - MV 镜头生成降档为 **960×544 @ 24fps**，经 AI 超分回 **1920×1080** 后按 **24fps** 交付（此前契约 1920×1080@30）。
    - **分辨率不违约**、全片 24fps 无 judder（不做 30fps 帧重采样）；仅帧率偏离契约。
    - 物料包内声明 1920×1080@30 的，平台按本口径统一交付；`timeline.frameRate` 缺省值改为 24。

    ## 接入环境
    - 生产环境 Base URL：`https://video.livepartner.fans`（HTTPS，nginx 反向代理至 video-studio:8780）
    - 文档页面：`https://video.livepartner.fans/api-docs.html`（图文版开放接口文档，含快速开始与示例）
    - 管理后台：`https://video.livepartner.fans/`（平台方客户/模板/任务管理）

    ## 通用约定
    - 统一前缀：`/v1`
    - 时间戳：秒级 Unix 时间戳
    - 任务号 `id`：16 位数字字符串（task_no），查询/操作时也兼容数字 id
    - 幂等：`POST /v1/video/generations` 支持 `Idempotency-Key` 请求头与请求体 `external_task_id`，二者均可做去重

    ## 作品回执（回调）——必配与两层兜底
    为保障作品交付，**甲方创建任务时应强制配置回调**，平台采用「两层兜底 + 降级兼容」机制确保甲方能收到回执：
    - **第 1 层（签名回调，推荐）**：任务进入终态（`done`/`failed`/`cancelled`）后，平台向 `callback.url` 投递 JSON，并带
      `X-Signature`（HMAC-SHA256）、`X-Timestamp`、`X-Nonce` 三个头做防重放签名，`event_id` 稳定幂等（同一任务不变）。
      甲方须校验签名后再信任务状态，防止伪造。
    - **第 2 层（降级兼容）**：若甲方**未配置 `api_secret`**，平台无法生成签名，会降级为**无签名回调**仍照常投递
      （带 `X-Unsigned: 1` 头明确标记，`callback_status=sent_unsigned`），甲方可识别非签名投递并自行决定信任级别。
    - **失败重试**：回调投递失败（非 2xx / 网络错误 / 超时）按退避序列自动重试（1/5/15/60/360/1440 分钟，最多 6 次），
      重试耗尽后置 `failed`；甲方可随时 `POST /v1/video/callbacks/replay` 手动重放，或 `GET /v1/video/generations/{id}` 轮询兜底。
    - **状态可查**：回调状态/次数/最后错误记录于任务详情（`callback_status`/`callback_attempts`/`callback_last_error`），便于排查。
    - **接入检查**：请向平台方申请并配置 `api_secret`（用于生成/校验 HMAC 签名）；回调 URL 须为平台可达的 HTTPS 公网地址。

    ## 任务状态机
    ```
    awaiting_materials → queued → rendering → done
          │                  │           │
          │                  └── failed ─┘
          └────────────────── cancelled（可退款）
    ```

    ## 错误格式
    所有错误响应统一为：
    ```json
    { "error": { "type": "invalid_request_error", "code": "4001", "message": "……", "retryable": false, "request_id": "……" } }
    ```

    ## MV 视频制作对接（供应商 = 物料提供方）
    MV 任务复用同一套任务接口（创建 / 查询 / 管理），另有**物料包契约**与**成片回传契约**，图文版见
    `https://video.livepartner.fans/api-docs.html#mv-provider`（§15）。本规范新增：

    - **平台入口**（供应商调用）：`POST /api/mv/inbound` —— 推送 `work_id` 后由平台自动拉取物料并投产。
    - **供应商需实现**（平台调用，Base URL = 供应商 `api_base`；规范内以 `x-implemented-by: provider` 标注）：
      `GET /v1/mv-video/materials/{workId}`、`POST /v1/mv-video/materials/{workId}/returns/complete`、
      `POST /v1/mv-video/materials/{workId}/returns/refresh`
      —— 可直接用生成器产出**服务端骨架**（含 DTO 与字段名），实现填空即可与平台契约对齐。
    - **镜头脚本规范**（JSON Schema，可接进打包 CI）：
      `https://video.livepartner.fans/docs/mv-shots.schema.json`；示例 `docs/mv-shots.example.json`。
    - **本地自查脚本**（与平台「渲染前自检闸门」同一口径，零依赖）：
      `https://video.livepartner.fans/docs/validate-mv-shots.mjs`
      ```bash
      node validate-mv-shots.mjs input/mv-shots.json --shots-dir=input/objects/shot --audio=input/objects/original-audio.mp3
      ```
    - **尾帧为硬要求**：`shots[].endImageUrl` **每一镜必填**（v3.4 起，原「可选、不提供则照旧」口径已作废）；
      包内缺失 / 上传失败 / 仅给包内相对路径 → 任务直接打回，不再静默退化为单首帧。
    - **海报镜（`segmentType: "poster"`）同样必须自带首尾帧**（2026-10-19 修正）：缺尾帧按 `error_code=MATERIAL_MISSING_END_FRAME`
      打回，**平台不自动补齐**。详见本节顶部「海报镜尾帧口径修正」。
    - **渲染前自检闸门（六项）**：① 镜头数 == `timeline.shotCount` == 包内分镜图张数；② `|Σ shots[].durationMs − timeline.durationMs| ≤ 200ms`；
      ③ `max(shots[].endMs) ≤ 音频实测时长`；④ `durationMs` 与音频实测偏差仅告警（整秒取整常见 300–500ms）；
      ⑤ 每一镜都有 `endImageUrl` 尾帧（缺帧打回）；⑥ 包内尾帧图数覆盖每一镜 + 主角定妆照 + **⑧ 海报镜首尾帧与提示词齐全**。
      任一不通过 → 任务转 `awaiting_materials` 且 `error_code=MV_SELFCHECK_FAILED`，`message` 回传实测数值，**不消耗算力、不出片**。
    - **音频基准**：MV 必须以原唱轨（`media.vocalUrl` / `media.originalVocalUrl`）为唯一基准，不得只提供伴奏。
    - **时长口径**：成片时长 = 音频实测时长（建议在 `timeline.audioDurationMs` 中提供），逐镜严格按 `shots[].durationMs` 排布（±1 帧）。
servers:
  - url: https://video.livepartner.fans
    description: 生产环境（正式域名，nginx 转发到 video-studio:8780）
  - url: http://134.175.153.142:8780
    description: 生产环境（video-studio 直连端口，备用；注意 IP 的 80 端口不转发 /v1/*）
tags:
  - name: Models
    description: 模型列表
  - name: Templates
    description: 模板列表/详情
  - name: Generations
    description: 视频任务创建与查询
  - name: Materials
    description: 素材上传（直传/托管）
  - name: Manage
    description: 任务管理（提交/取消/退款/重试/刷新/重放回调）
  - name: Billing
    description: 余额与账单
  - name: Account
    description: 账户信息
  - name: MV
    description: MV 作品管理（平台侧，需 x-admin-token）
  - name: MV 平台入口
    description: 供应商推送作品、平台自动拉物料并投产（平台侧接口）
  - name: MV 供应商实现
    description: 由供应商实现、平台调用（Base URL = 供应商 api_base）；可用生成器产出服务端骨架
security:
  - bearerAuth: []
paths:
  /v1/models:
    get:
      tags: [Models]
      operationId: listModels
      summary: 模型列表
      description: 返回当前支持的视频生成模型。
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object:
                    type: string
                    example: list
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Model"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/templates:
    get:
      tags: [Templates]
      operationId: listTemplates
      summary: 模板列表
      description: |
        返回可用模板。可选的 `category`、`status` 过滤；`status=enabled` 表示启用中。
        模板的 `material_spec` 是该模板的素材规格（必需项/可选项与约束），创建任务前应据此准备素材。
      parameters:
        - name: category
          in: query
          required: false
          schema:
            type: string
            example: 音乐MV
        - name: status
          in: query
          required: false
          description: 上架状态过滤。默认 `enabled`（仅已上架）；传 `all` 返回全部（含停用）
          schema:
            type: string
            enum: [enabled, disabled, all]
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object:
                    type: string
                    example: list
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Template"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/templates/{code}:
    get:
      tags: [Templates]
      operationId: getTemplate
      summary: 模板详情
      parameters:
        - name: code
          in: path
          required: true
          description: 模板编码，如 `CD-2024-005`
          schema:
            type: string
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Template"
        "404":
          description: "模板不存在（code: 4041）"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/generations:
    post:
      tags: [Generations]
      operationId: createGeneration
      summary: 创建视频生成任务
      description: |
        创建生成任务。**素材策略**（重要）：
        - 不传任何素材 → 任务进入 `awaiting_materials`，响应返回 `missing_materials` 缺失清单；
        - 携带内联 `materials`（已齐全）→ 直接 `queued`；
        - 携带 `material_package`（对象存储契约）→ `queued`，平台异步拉取，未拉取成功前不会生成；
        - 平台不会用模板内置的测试素材包替代甲方素材。
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: 幂等键，重复提交同一键返回首次结果
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateGenerationRequest"
      responses:
        "200":
          description: 创建成功（含幂等命中）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Generation"
        "400":
          description: 参数错误 / 模板停用（4007）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: 鉴权失败（4011/4012）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "402":
          description: 余额不足（4029）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: 模板不存在（4041）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        default:
          $ref: "#/components/responses/ErrorResponse"
    get:
      tags: [Generations]
      operationId: listGenerations
      summary: 任务列表
      description: 按创建时间倒序返回任务，最多 50 条。
      parameters:
        - name: limit
          in: query
          required: false
          description: 返回数量上限
          schema:
            type: integer
            default: 20
            maximum: 50
        - name: external_task_id
          in: query
          required: false
          description: 按外部任务号过滤
          schema:
            type: string
        - name: status
          in: query
          required: false
          description: 按状态过滤
          schema:
            type: string
            enum: [awaiting_materials, queued, rendering, done, failed, cancelled]
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object:
                    type: string
                    example: list
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Generation"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/generations/{task_id}:
    get:
      tags: [Generations]
      operationId: getGeneration
      summary: 查询任务
      description: 通过 16 位任务号或数字 id 查询。`status=done` 时 `result` 携带成片下载地址。
      parameters:
        - name: task_id
          in: path
          required: true
          description: 16 位任务号（或数字 id）
          schema:
            type: string
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Generation"
        "404":
          description: 任务不存在
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/materials:
    post:
      tags: [Materials]
      operationId: uploadMaterial
      summary: 上传素材（平台托管）
      description: |
        以 multipart/form-data 向平台上传单个素材文件，`type` 取模板 `material_spec.required/optional` 中的 `key`。
        素材齐全后任务自动进入 `queued`。
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [task_id, type, file]
              properties:
                task_id:
                  type: string
                  description: 任务号（创建任务的返回值 id）
                type:
                  type: string
                  description: 素材类型 key，如 `poster`、`cover`、`original_vocal`
                file:
                  type: string
                  format: binary
                  description: 素材文件
      responses:
        "200":
          description: 上传成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MaterialUploadResult"
        "400":
          description: 参数错误 / 素材不匹配（4006）/ 文件过大
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/materials/presign:
    post:
      tags: [Materials]
      operationId: presignMaterial
      summary: 申请素材直传（OSS 预签名 PUT）
      description: |
        甲方在自有存储时使用：先申请预签名地址，用 PUT 直传文件，再调用 confirm 登记。
        `filename` 需带正确扩展名（服务端会校验格式），`type` 同上。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_id, type, filename]
              properties:
                task_id:
                  type: string
                type:
                  type: string
                  description: 素材类型 key
                filename:
                  type: string
                  description: 文件名，含扩展名，如 `poster.png`
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PresignResult"
        "400":
          description: 参数错误 / 格式不支持
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/materials/confirm:
    post:
      tags: [Materials]
      operationId: confirmMaterial
      summary: 登记直传素材
      description: 用 presign 返回的 `oss_key` 登记已直传完成的文件。登记成功且素材齐全后任务进入 `queued`。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_id, oss_key]
              properties:
                task_id:
                  type: string
                oss_key:
                  type: string
                  description: presign 返回的 oss_key
                type:
                  type: string
                  description: 素材类型 key（可省略，服务端会从 oss_key 推导）
                filename:
                  type: string
                  description: 文件名（可省略）
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MaterialUploadResult"
        "400":
          description: 参数错误 / OSS 对象不存在 / 前缀不匹配（4006）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/generations/{task_id}/commit:
    post:
      tags: [Manage]
      operationId: commitGeneration
      summary: 显式提交任务
      description: 素材未齐全时提交返回 409 及缺失清单；素材齐全但任务仍处于 `awaiting_materials` 时调用可立即排队。
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Generation"
        "409":
          description: 素材不齐，响应含 missing_materials
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    $ref: "#/components/schemas/Error"
                  missing_materials:
                    type: array
                    items:
                      type: string
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/generations/{task_id}/cancel:
    post:
      tags: [Manage]
      operationId: cancelGeneration
      summary: 取消任务
      description: 未开始渲染的任务可取消；已扣费的可退款（见 refund）。
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Generation"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/generations/{task_id}/refund:
    post:
      tags: [Manage]
      operationId: refundGeneration
      summary: 退款
      description: 对已扣费但未完成的失败/取消任务退回额度。
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    type: string
                  id:
                    type: string
                  status:
                    type: string
                  refunded:
                    type: boolean
                  usage:
                    type: object
                  message:
                    type: string
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/generations/{task_id}/retry:
    post:
      tags: [Manage]
      operationId: retryGeneration
      summary: 重试失败任务
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Generation"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/generations/{task_id}/refresh:
    post:
      tags: [Manage]
      operationId: refreshGeneration
      summary: 刷新签名地址
      description: 重新生成成片下载地址/素材包直传地址的预签名 URL（旧地址过期时使用）。
      parameters:
        - name: task_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Generation"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/video/callbacks/replay:
    post:
      tags: [Manage]
      operationId: replayCallback
      summary: 重放回调
      description: 按事件 ID 重放某次回调投递。
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event_id]
              properties:
                event_id:
                  type: string
                  description: 回调事件 ID（回调事件中的 id 字段）
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    type: string
                  event_id:
                    type: string
                  callback_status:
                    type: string
                  callback_attempts:
                    type: integer
                  callback_last_code:
                    type: integer
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/billing/transactions:
    get:
      tags: [Billing]
      operationId: listTransactions
      summary: 账单流水
      description: 返回扣费/退款流水，按时间倒序。
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                type: object
                required: [object, data]
                properties:
                  object:
                    type: string
                    example: list
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Transaction"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/user/balance:
    get:
      tags: [Billing]
      operationId: getBalance
      summary: 账户余额
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Balance"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /v1/me:
    get:
      tags: [Account]
      operationId: getMe
      summary: 账户信息
      responses:
        "200":
          description: 成功
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        default:
          $ref: "#/components/responses/ErrorResponse"
  /api/mv/works:
    get:
      tags: [MV]
      operationId: listMvWorks
      summary: MV 作品列表（管理端）
      description: 需管理员令牌 `x-admin-token`；返回各作品状态与镜头完成度。详见 /mv-api-docs.html
      parameters:
        - name: x-admin-token
          in: header
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 成功
        default:
          $ref: "#/components/responses/ErrorResponse"
    post:
      tags: [MV]
      operationId: createMvWork
      summary: 登记 MV 作品（管理端）
      description: 提交 work_id / api_base / access_key 后平台拉取甲方物料并进入制作流程
      parameters:
        - name: x-admin-token
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [work_id, api_base, access_key]
              properties:
                work_id: { type: string, example: "683" }
                api_base: { type: string, example: "https://api.example.com" }
                access_key: { type: string, example: "mv_xxxxxxxx" }
                pull: { type: boolean, example: true, description: 是否立即拉取物料 }
      responses:
        "200":
          description: 成功
        default:
          $ref: "#/components/responses/ErrorResponse"
  /api/mv/works/{workId}:
    get:
      tags: [MV]
      operationId: getMvWork
      summary: MV 作品详情（含镜头级状态）
      parameters:
        - name: x-admin-token
          in: header
          required: true
          schema: { type: string }
        - name: workId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 成功
        default:
          $ref: "#/components/responses/ErrorResponse"
  /api/mv/works/{workId}/start:
    post:
      tags: [MV]
      operationId: startMvWork
      summary: 启动 / 继续 MV 制作（幂等，已完成镜头自动跳过）
      parameters:
        - name: x-admin-token
          in: header
          required: true
          schema: { type: string }
        - name: workId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 成功
        default:
          $ref: "#/components/responses/ErrorResponse"
  /api/mv/works/{workId}/pull:
    post:
      tags: [MV]
      operationId: pullMvMaterials
      summary: 重新拉取甲方物料（票据过期或脚本更新后）
      parameters:
        - name: x-admin-token
          in: header
          required: true
          schema: { type: string }
        - name: workId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 成功
        default:
          $ref: "#/components/responses/ErrorResponse"
  /api/mv/works/{workId}/shots:
    get:
      tags: [MV]
      operationId: listMvShots
      summary: 镜头级状态查询（甲方，凭 worker 管理令牌）
      description: |
        鉴权二选一：
        ① 请求头 `x-mv-admin-token`（乙方发放的 worker 管理令牌，甲方配置到系统 `mv_worker_admin_token`）—— 可查全部作品；
        ② 请求头 `Authorization: Bearer <api_key>`（甲方现有 /v1 凭据）—— **按作品归属隔离**，仅能查询自己客户名下的作品。
        查询指定作品的**逐镜头**生成状态、生成种子与质检指标（含风格抽帧检查 `style`）。
        只读；无凭据 401，管理令牌未配置且无凭据 503。
      security:
        - mvAdminToken: []
        - bearerAuth: []
      parameters:
        - name: workId
          in: path
          required: true
          schema: { type: string }
          description: 作品 ID
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [pending, generating, done, failed] }
          description: 可选，按镜头状态过滤
      responses:
        "200":
          description: 镜头级状态清单（work 概要 + shots 逐镜明细）
        "401":
          description: worker 管理令牌无效
        "503":
          description: 乙方未配置 mv_worker_admin_token
        default:
          $ref: "#/components/responses/ErrorResponse"
  /api/mv/works/{workId}/shots/{seg}/{idx}/reset:
    post:
      tags: [MV]
      operationId: resetMvShot
      summary: 重置单个镜头重做
      parameters:
        - name: x-admin-token
          in: header
          required: true
          schema: { type: string }
        - name: workId
          in: path
          required: true
          schema: { type: string }
        - name: seg
          in: path
          required: true
          schema: { type: integer }
        - name: idx
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: 成功
        default:
          $ref: "#/components/responses/ErrorResponse"

  /api/mv/inbound:
    post:
      tags: [MV 平台入口]
      operationId: mvInbound
      summary: 接单入口（供应商推送作品 → 平台拉物料并投产）
      description: |
        供应商推送作品 ID，平台自动登记作品并使用请求里的 `api_base` + `access_key`
        （或平台预置默认值）调用供应商物料接口 `GET /v1/mv-video/materials/{workId}` 拉取物料，随后进入生产管线。
        **幂等**：同一 `work_id` 重复推送不会重复建作品，仅更新凭据并重新拉物料。
        **鉴权**：请求头 `x-mv-inbound-token`（平台方发放）；未配置令牌时平台拒绝接单（`503` / `5031`），令牌错误 `401`。
        **后续**：拉物料 → 素材入库 → 渲染前自检闸门（不通过则 `awaiting_materials` + `error_code=MV_SELFCHECK_FAILED`）
        → 逐镜图生视频 → 拼接 → 回传。
      security:
        - mvInboundToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MvInboundRequest"
            example:
              work_id: "757"
              api_base: "https://api.example.com"
              access_key: "mv_xxxxxxxx"
      responses:
        "200":
          description: 已受理（`pull_error` 非空 = 登记成功但物料未就绪，平台会按策略重试）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MvInboundResponse"
        "400":
          description: 缺少 work_id，或 api_base / access_key 缺失且平台无默认值
        "401":
          description: 接单令牌无效
        "503":
          description: 平台未配置接单令牌（5031）
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/mv-video/materials/{workId}:
    get:
      tags: [MV 供应商实现]
      x-implemented-by: provider
      servers:
        - url: "{api_base}"
          description: 供应商物料接口基址（登记/推送作品时提供）
          variables:
            api_base:
              default: https://api.example.com
      operationId: getMvMaterials
      summary: 物料包下发（供应商实现，平台调用）
      description: |
        平台以 `Authorization: Bearer <access_key>` 调用本接口拉取物料包。`ready=false` 时平台记录待就绪并按策略重试，
        **不会使用任何其它来源的素材顶替**。字段硬性要求见 `MvMaterialResponse` 的字段描述与文档 §15.3。

        提交前建议本地自查（与平台自检闸门同一口径）：
        `node validate-mv-shots.mjs input/mv-shots.json --shots-dir=input/objects/shot --audio=input/objects/original-audio.mp3`
      security:
        - providerBearerAuth: []
      parameters:
        - name: workId
          in: path
          required: true
          schema: { type: string }
          description: 作品 ID
      responses:
        "200":
          description: 物料包（含镜头脚本、分镜图地址、音轨、交付规格与回传票据）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MvMaterialResponse"
        "401":
          description: access_key 无效
        "404":
          description: 作品不存在
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/mv-video/materials/{workId}/returns/complete:
    post:
      tags: [MV 供应商实现]
      x-implemented-by: provider
      servers:
        - url: "{api_base}"
          description: 供应商物料接口基址（登记/推送作品时提供）
          variables:
            api_base:
              default: https://api.example.com
      operationId: completeMvReturn
      summary: 回传完成确认（供应商实现，平台调用）
      description: |
        平台把 MP4 以 `Content-Type: video/mp4` PUT 到物料包 `materialPackage.result_upload.url`（预签名地址）后，
        调用本接口提交 `objectKey` / `sha256` / `bytes` 做完整性对账；`eventId` 幂等（重复提交返回 idempotent）。
        PUT 返回 `200/201/204` 均视为上传成功。
      security:
        - mvReturnToken: []
      parameters:
        - name: workId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MvCompleteRequest"
            example:
              objectKey: temp/mv-video/757/output/final.mp4
              sha256: 9f2c...（64 位小写 hex，与实际文件逐字节一致）
              bytes: 58741233
              eventId: evt-20260924-0001
      responses:
        "200":
          description: 已确认（重复提交返回 `idempotent`）
        "400":
          description: sha256 / bytes 与文件不一致，请重新上传
        "401":
          description: 回传票据无效或已过期（走 `returns/refresh` 换票）
        default:
          $ref: "#/components/responses/ErrorResponse"

  /v1/mv-video/materials/{workId}/returns/refresh:
    post:
      tags: [MV 供应商实现]
      x-implemented-by: provider
      servers:
        - url: "{api_base}"
          description: 供应商物料接口基址（登记/推送作品时提供）
          variables:
            api_base:
              default: https://api.example.com
      operationId: refreshMvReturn
      summary: 刷新物料/票据（供应商实现，平台调用）
      description: 回传票据过期或镜头脚本更新时，平台调用本接口换取新的物料地址与 `providerReturn.returnToken`（**不新建任务**，已完成的镜头成果保留）。
      security:
        - mvReturnToken: []
      parameters:
        - name: workId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: 最新物料包（结构同 `GET /v1/mv-video/materials/{workId}`）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MvMaterialResponse"
        "401":
          description: 回传票据无效或已过期
        default:
          $ref: "#/components/responses/ErrorResponse"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 供应商 API Key，登录平台后台获取
    mvInboundToken:
      type: apiKey
      in: header
      name: x-mv-inbound-token
      description: MV 接单令牌（平台方发放，用于 `POST /api/mv/inbound`）
    mvReturnToken:
      type: apiKey
      in: header
      name: X-MV-Return-Token
      description: MV 回传票据（取自物料包 `providerReturn.returnToken`，有效期 7 天）
    mvAdminToken:
      type: apiKey
      in: header
      name: x-mv-admin-token
      description: 乙方 worker 管理令牌（星河发放，甲方配置到系统 `mv_worker_admin_token`），用于镜头级状态查询
    providerBearerAuth:
      type: http
      scheme: bearer
      description: "平台调用供应商物料接口时携带的凭据（Authorization: Bearer <access_key>）"
  responses:
    ErrorResponse:
      description: 错误
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - invalid_api_key
                - insufficient_quota
                - not_found
                - conflict
                - rate_limit_exceeded
                - server_error
              example: invalid_request_error
            code:
              type: string
              description: 业务错误码
              examples:
                - "4001"   # 参数缺失/校验失败
                - "4006"   # 素材与任务不匹配 / OSS 对象校验失败
                - "4007"   # 模板已停用
                - "4011"   # 缺少 API Key
                - "4012"   # API Key 无效
                - "4029"   # 余额不足
                - "4041"   # 模板不存在
                - "4090"   # 状态冲突
                - "4290"   # 频率限制
                - "5000"   # 服务器内部错误
                - "5002"   # 素材缺失/未就绪（materialize 拒绝）
            message:
              type: string
              example: "参数缺失: template_id"
            retryable:
              type: boolean
              default: false
            request_id:
              type: string
    Model:
      type: object
      properties:
        object:
          type: string
          example: model
        id:
          type: string
          example: video-lora-v3
        created:
          type: integer
        owned_by:
          type: string
        description:
          type: string
    Template:
      type: object
      properties:
        object:
          type: string
          example: template
        id:
          type: integer
        code:
          type: string
          example: CD-2024-005
        name:
          type: string
          example: 星光1号
        category:
          type: string
        status:
          type: string
          enum: [enabled, disabled]
        version:
          type: string
        ratio:
          type: string
          example: "16:9"
        resolution:
          type: string
          example: "1080P"
        desc:
          type: string
        description:
          type: string
        cover_url:
          type: string
        thumbnail:
          type: string
        sample_url:
          type: string
        sample:
          type: string
        preview:
          type: string
        duration:
          type: string
        price:
          type: number
        default_plugin:
          type: string
          description: 模板默认渲染插件编号（plugin-NNN），任务未指定 plugin_id 时使用
        template_type:
          type: string
          enum: [generator, project]
          description: 模板类型。generator=生成式模板（声明驱动组装）；project=工程模板（含模板包 project_file 工程文件，可经 AI 翻译官生成 slot schema）
        renderer:
          type: string
          enum: [kdenlive, ffmpeg, xinghe]
          description: 模板指定的渲染后端（阶段四 P7）。工作机按 capabilities.render_backends 匹配认领；默认 kdenlive。xinghe=星河字面（服务端浏览器内编码，不经渲染机，见顶部「星河字面（文字 PV）CD 模板」）
        ai_mode:
          type: string
          enum: [template-direct, ai-assisted]
          description: AI 模式。template-direct=模板直接渲染；ai-assisted=AI 辅助（翻译官适配工程模板时使用）
        design_resolution:
          type: object
          description: 设计分辨率（模板原始工程分辨率），如 {width:1920, height:1080, fps:30}
        supported_resolutions:
          type: array
          description: 模板支持的分辨率列表。创建任务传 output_resolution 必须命中其中一项（宽/高/fps 全部匹配），否则返回 4008
          items:
            type: object
            properties:
              width:
                type: integer
              height:
                type: integer
              fps:
                type: integer
        decorations_schema:
          type: object
          description: 模板装饰 schema（阶段一 P5）。固定五项 intro/ending/watermark/corner_tl/corner_tr，每项 {plugin, enabled, params}；任务级 decorations 在此基础之上覆盖
        material_spec:
          type: object
          description: 素材规格。required/optional 数组内元素为 {key,name,desc,constraints}
          properties:
            ratio:
              type: string
            default_resolution:
              type: string
            doc_url:
              type: string
            required:
              type: array
              items:
                $ref: "#/components/schemas/MaterialSpecItem"
            optional:
              type: array
              items:
                $ref: "#/components/schemas/MaterialSpecItem"
    MaterialSpecItem:
      type: object
      properties:
        key:
          type: string
          description: 素材类型 key，上传素材时 type 字段
          example: poster
        name:
          type: string
          example: 海报图
        desc:
          type: string
        constraints:
          type: object
          properties:
            format:
              type: array
              items:
                type: string
            max_size_mb:
              type: number
            min_dimension:
              type: string
            count:
              oneOf:
                - type: integer
                - type: object
                  properties:
                    min:
                      type: integer
                    max:
                      type: integer
            order:
              type: integer
    DecorationItem:
      type: object
      description: 装饰组件单项（固定五项 intro/ending/watermark/corner_tl/corner_tr）。plugin:null 表示不启用该效果；enabled:false 时忽略 plugin 与 params
      properties:
        plugin:
          type: string
          pattern: ^plugin-\d{3,}$
          description: 渲染插件编号（如 plugin-001），装饰效果由插件实现；null/缺省=不启用
        enabled:
          type: boolean
          description: 独立开关（默认 false）；true=启用
        params:
          type: object
          description: 插件参数（由插件声明，按 key 级覆盖模板默认值）
    CreateGenerationRequest:
      type: object
      required: [template_id]
      properties:
        template_id:
          oneOf:
            - type: string
              description: 模板编码，如 CD-2024-005
            - type: integer
              description: 模板数字 id
        prompt:
          type: string
          description: 创意/文案提示词
        cust_user_id:
          type: string
          description: 甲方侧的用户 ID（可选，用于审计）
        external_task_id:
          type: string
          description: 甲方侧任务号，用于幂等/对账
        model:
          type: string
          description: 模型 id，默认取模板配置
        resolution:
          type: string
          enum: [720P, 1080P]
          description: 输出分辨率，默认 1080P
        watermark:
          oneOf:
            - type: boolean
              description: true=使用客户默认水印（后台配置）；false/省略=无水印
            - type: object
              description: 水印对象。图片水印与文字水印二者选其一（默认图片）。enabled=false 关闭水印
              required: [type]
              properties:
                enabled:
                  type: boolean
                  description: 水印独立开关（可选，默认 true）；false=关闭水印
                type:
                  type: string
                  enum: [image, text]
                  description: 水印类型：image=图片水印（只作水印叠加，绝不给封面/头像/Logo 用），text=文字水印。省略 type 时自动推断——有 image_url→image，有 text→text，都没有→默认 image（走客户默认水印图片）
                image_url:
                  type: string
                  format: uri
                  description: 图片水印素材 URL（type=image 时必填；http(s) 地址，服务端下载并做安全策略校验）
                text:
                  type: string
                  description: 文字水印主文字（type=text 时必填，如"星光音乐相册 · 版权所有"）
                sub_text:
                  type: string
                  description: 文字水印次行文字（可选，如网址，与主文字两行显示）
                position:
                  type: string
                  enum: [top-left, top-right, bottom-left, bottom-right]
                  description: 水印固定角位（可选；缺省按模板漂浮动画）
                opacity:
                  type: number
                  minimum: 0
                  maximum: 1
                  description: 水印透明度（可选，默认 0.6）
        intro:
          type: object
          description: 片头独立开关与资源。enabled=false 关闭（退回模板默认片头）；省略=按模板默认
          properties:
            enabled:
              type: boolean
              description: 片头开关（可选，默认 true）
            url:
              type: string
              format: uri
              description: 片头素材 URL（http(s)，图片或视频）
            type:
              type: string
              enum: [image, video]
              description: 片头素材类型（可选，默认 image）
        ending:
          type: object
          description: 片尾独立开关与资源。enabled=false 关闭（退回模板默认片尾）；省略=按模板默认
          properties:
            enabled:
              type: boolean
              description: 片尾开关（可选，默认 true）
            url:
              type: string
              format: uri
              description: 片尾素材 URL（http(s)，图片或视频）
            type:
              type: string
              enum: [image, video]
              description: 片尾素材类型（可选，默认 image）
        labels:
          type: object
          description: 播放器标签独立开关与覆盖文本
          properties:
            player1:
              type: object
              description: 左上角歌名标签（播放器1）。图片固定为作品封面，上方文字为歌名，下方文字为词/曲作者（绝不显示演唱者）
              properties:
                enabled:
                  type: boolean
                  description: 是否显示左上角歌名标签（可选，默认 true）；false=隐藏
                title:
                  type: string
                  description: 歌名主标题覆盖文本（可选，优于 song_info.song_name）
                author:
                  type: string
                  description: 作者行覆盖文本（可选，默认按 song_info.lyricist/composer/songwriter 组装为"词：X 曲：Y"）
            player2:
              type: object
              description: 右上角会员卡标签（播放器2）。图片固定为该用户头像，上方文字为会员昵称，下方文字为平台标识名
              properties:
                enabled:
                  type: boolean
                  description: 是否显示右上角会员卡标签（可选，默认 true）；false=隐藏
                user_name:
                  type: string
                  description: 会员昵称覆盖文本（可选，优于 song_info.user_name）
                platform_name:
                  type: string
                  description: 平台名称覆盖文本（可选，优于 song_info.platform_name）
                level:
                  type: string
                  description: 会员等级覆盖文本（可选，优于 song_info.member_level）
        song_info:
          type: object
          description: 歌曲与用户元数据，用于播放器卡片渲染（歌名/词曲作者/歌词/昵称/等级/平台）
          properties:
            song_name:
              type: string
              description: 歌名（播放器1 左上角主标题）
            lyricist:
              type: string
              description: 词作者（显示为"词：xxx"）
            composer:
              type: string
              description: 曲作者（显示为"曲：xxx"）
            lyrics:
              type: string
              description: 歌词文本（卡拉OK字幕，可选）
            user_name:
              type: string
              description: 用户昵称（播放器2 右上角会员卡昵称）
            member_level:
              type: string
              description: 会员等级（如"一级音乐人"）
            platform_name:
              type: string
              description: 平台名称
            cover_url:
              type: string
              format: uri
              description: 歌曲封面 URL（可选，替代素材槽位 cover）
            user_avatar_url:
              type: string
              format: uri
              description: 用户头像 URL（可选，替代素材槽位 avatar）
            platform_logo_url:
              type: string
              format: uri
              description: 平台 Logo URL（可选，替代素材槽位 logo）
        plugin_id:
          type: string
          pattern: ^plugin-\d{3,}$
          description: 渲染插件编号（如 plugin-001）。不传时使用模板的 default_plugin；两者皆无则用平台内置播放器
        decorations:
          type: object
          description: 装饰组件（阶段一 P5）。固定五项，逐项 {plugin, enabled, params}；仅需覆盖模板默认时可省略。三层配置合并：平台全局默认 < 模板 decorations_schema < 任务 decorations。错误统一返回 4008
          additionalProperties: false
          properties:
            intro:
              $ref: "#/components/schemas/DecorationItem"
            ending:
              $ref: "#/components/schemas/DecorationItem"
            watermark:
              $ref: "#/components/schemas/DecorationItem"
            corner_tl:
              $ref: "#/components/schemas/DecorationItem"
            corner_tr:
              $ref: "#/components/schemas/DecorationItem"
        output_resolution:
          type: object
          description: 动态输出规格（阶段一 P1）。必须命中模板 supported_resolutions 中某一项（width/height 必填正整数，fps 可选正整数），否则返回 4008。省略时用 resolution 或模板默认分辨率
          properties:
            width:
              type: integer
              description: 输出宽（像素），须命中 supported_resolutions
            height:
              type: integer
              description: 输出高（像素），须命中 supported_resolutions
            fps:
              type: integer
              description: 帧率（可选，省略时取 supported_resolutions 匹配项帧率）
        callback:
          oneOf:
            - type: string
              format: uri
              description: 回调地址（任务完成/失败时 POST，兼容纯 URL 字符串）
            - type: object
              description: 回调配置对象
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: 回调地址（平台可达的 HTTPS 公网地址）
          description: >
            作品回执回调（建议必配）：任务进入终态后平台向该地址投递 JSON。
            配置了 api_secret 时走签名回调（X-Signature/X-Timestamp/X-Nonce，event_id 幂等）；
            未配置 api_secret 时降级为无签名回调（带 X-Unsigned: 1）仍照常投递；
            投递失败按退避序列自动重试，可用 POST /v1/video/callbacks/replay 重放或用 GET 轮询兜底。
        priority:
          type: string
          enum: [low, normal, high]
          default: normal
        materials:
          type: object
          description: 内联素材：key 为模板 material_spec 中的类型 key
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                filename:
                  type: string
                url:
                  type: string
                size:
                  type: integer
        material_package:
          type: object
          description: 对象存储素材包契约（由乙方预先提供 bucket/前缀）
          properties:
            bucket:
              type: string
            prefix:
              type: string
            object_key:
              type: string
            sha256:
              type: string
        result_upload:
          type: object
          description: 成片回传配置
          properties:
            bucket:
              type: string
            prefix:
              type: string
            upload_url:
              type: string
        delivery_url:
          type: string
          description: 成品交付地址（可选）
    Generation:
      type: object
      required: [object, id, status]
      properties:
        object:
          type: string
          example: video.generation
        id:
          type: string
          description: 16 位任务号
          example: "1234567890123456"
        display_id:
          type: string
        name:
          type: string
        external_task_id:
          type: string
        created:
          type: integer
          description: Unix 秒
        model:
          type: string
        status:
          type: string
          enum: [awaiting_materials, queued, rendering, done, failed, cancelled]
        status_version:
          type: integer
        template_id:
          type: string
        plugin_id:
          type: string
          description: 本次任务实际使用的渲染插件编号（任务级覆盖，否则为模板默认值）
        decorations:
          type: object
          description: 装饰组件合并结果回显（三层合并：全局默认 < 模板 schema < 任务覆盖，五项 intro/ending/watermark/corner_tl/corner_tr）
        output_resolution:
          type: object
          description: 动态输出规格回显 {width,height,fps}（来自任务 render_params.output_resolution_spec），null 表示未指定
        watermark:
          oneOf:
            - type: "null"
            - type: object
              description: 水印配置回显（含 enabled/type/position/opacity 等）
        intro:
          oneOf:
            - type: "null"
            - type: object
              description: 片头开关与资源回显
        ending:
          oneOf:
            - type: "null"
            - type: object
              description: 片尾开关与资源回显
        labels:
          oneOf:
            - type: "null"
            - type: object
              description: 播放器标签开关与覆盖文本回显
        resolution:
          type: string
        progress:
          type: integer
        estimate_seconds:
          type: integer
          description: 预计等待/处理时长（秒）。基于近 20 个已完成任务的中位纯处理时长（finished_at - started_at，不含排队）+ 当前排队任务分摊到并发槽位（在线工作机 × 单机并发 3）动态估算；无人排队时即接近单任务真实处理时长；无历史样本时兜底 420
        missing_materials:
          type: array
          description: 待补充的素材类型 key（status=awaiting_materials 时返回）
          items:
            type: string
          example: [poster, cover, original_vocal, accompaniment, word_lyrics, line_lyrics, album_images, user_avatar, edit_script]
        invalid_materials:
          type: array
          description: 素材包整改问题项（结构化字符串数组，逐条给出问题与整改方向；与 `error` 整段备注同源）
          items:
            type: string
          example: ["MV 自检闸门不通过（已停止出片）：① 镜头数 44 ≠ timeline.shotCount 23｜实测：镜头数=44 Σ镜头=184950ms timeline.durationMs=122000ms maxEnd=185000ms 音频实测=122376ms"]
        material_acceptance:
          type: object
          description: 各素材的验收状态
        result:
          type: object
          description: status=done 时的成片信息
          properties:
            url:
              type: string
              description: 成片下载地址（预签名，有过期时间）
            duration:
              type: number
            size:
              type: integer
            sha256:
              type: string
            mime:
              type: string
            width:
              type: integer
            height:
              type: integer
            expires_in:
              type: integer
        error:
          type: object
          description: status=failed 时的错误
        delivery_url:
          type: string
        callback:
          type: string
        callback_status:
          type: string
        retry_count:
          type: integer
        material_package_status:
          type: string
        result_upload_status:
          type: string
        error_code:
          type: string
          description: |
            任务级错误码（`status=awaiting_materials` / `failed` 时返回），便于程序化处理。
            素材与 MV 相关取值：`MISSING_SHOT_SCRIPT`（包内缺镜头脚本本体）、`MATERIAL_SPEC_REJECTED`（分类与媒体类型不符，
            或音频与镜头脚本不是同一版）、`MV_SELFCHECK_FAILED`（渲染前自检闸门不通过，`message` 内含四个实测数值）、
            `MATERIAL_PARSE_TIMEOUT` / `MATERIAL_PARSE_FAILED`（素材包解析超时 / 失败）。
            整改动作见文档 §12 与 §15.7。
          example: MV_SELFCHECK_FAILED
        error_detail:
          oneOf:
            - type: "null"
            - type: object
          description: 结构化错误详情（平台记录时返回）
        ingest_note:
          type: string
          description: 素材包级备注（SHA-256 校验失败、下载地址过期、包内容不合规等）
        mv_stage:
          type: string
          description: MV 专有阶段：`importing`（解析物料包）/ `i2v`（逐镜图生视频）/ `assemble`（拼接与渲染）
          example: i2v
        mv_shot_done:
          type: integer
          description: MV 已完成镜头数（逐镜图生视频进度）
          example: 17
        mv_shot_total:
          type: integer
          description: MV 镜头总数（= 镜头脚本 `shots.length` / `timeline.shotCount`）
          example: 23
        finished_at:
          type: integer
        created_at:
          type: string
    MaterialUploadResult:
      type: object
      properties:
        object:
          type: string
          example: video.material
        id:
          type: string
        display_id:
          type: string
        type:
          type: string
        filename:
          type: string
        url:
          type: string
        size:
          type: integer
        task_status:
          type: string
          description: 上传后任务的最新状态
        missing_materials:
          type: array
          items:
            type: string
        message:
          type: string
    PresignResult:
      type: object
      properties:
        object:
          type: string
        task_id:
          type: string
        display_id:
          type: string
        type:
          type: string
        oss_key:
          type: string
          description: 直传完成后提交给 confirm 的凭证
        put_url:
          type: string
          format: uri
          description: 直传 PUT 地址（预签名；有效期 24 小时 = 86400 秒；签名<b>不校验</b> Content-Type，按实际文件类型填写或省略均可）
        content_type:
          type: string
          description: 兼容字段（2026-09-27 起签名不再校验该请求头，取值仅供参考）
        expires_at:
          type: string
          description: 过期绝对时间（ISO8601），便于客户端换算
        expires_in:
          type: integer
          description: 有效期秒数（当前 86400 = 24 小时）
        message:
          type: string
    Balance:
      type: object
      properties:
        object:
          type: string
          example: balance
        quota:
          type: number
          description: 总充值额度
        used:
          type: number
          description: 已用额度
        remaining:
          type: number
          description: 剩余额度
        amount:
          type: number
          description: 可用余额（remaining 别名）
    Organization:
      type: object
      properties:
        object:
          type: string
          example: organization
        id:
          type: integer
        name:
          type: string
        balance:
          $ref: "#/components/schemas/Balance"
        status:
          type: string
        created_at:
          type: string
    Transaction:
      type: object
      properties:
        object:
          type: string
          example: billing.transaction
        id:
          type: integer
        task_id:
          type: string
        display_id:
          type: string
        type:
          type: string
          description: 流水类型（charge/refund/...）
        amount:
          type: number
        balance_before:
          type: number
        balance_after:
          type: number
        note:
          type: string
        created_at:
          type: string

    # ===== MV 视频制作对接（物料契约 · 回传契约）=====
    MvInboundRequest:
      type: object
      required: [work_id]
      properties:
        work_id:
          type: string
          description: 作品 ID（兼容 `workId` / `id`）
          example: "757"
        api_base:
          type: string
          description: 供应商物料接口基址（省略时使用平台预置默认值）
          example: https://api.example.com
        access_key:
          type: string
          description: 平台调用该供应商物料接口所用的密钥（省略时使用平台预置默认值）
          example: mv_xxxxxxxx

    MvInboundResponse:
      type: object
      properties:
        code:
          type: integer
          example: 0
        data:
          type: object
          properties:
            work_id:
              type: string
              example: "757"
            duplicated:
              type: boolean
              description: true = 该作品此前已登记（幂等复用，仅更新凭据并重新拉物料）
            shot_total:
              type: integer
              description: 解析到的镜头数；0 表示物料未就绪或包内缺少镜头脚本
              example: 23
            pull_error:
              oneOf:
                - type: "null"
                - type: string
              description: 拉物料失败原因（失败不抛错，平台记录并按策略重试）
            started:
              type: boolean
              description: 是否已自动进入生产管线
            status:
              type: string
              enum: [registered, ready]
              description: registered = 登记成功但物料未就绪；ready = 物料已就绪

    MvMedia:
      type: object
      description: 音轨。MV **必须**提供原唱轨（R3），不得只提供伴奏。
      properties:
        vocalUrl:
          type: string
          description: ★ 原唱轨地址（公网可下载）。作为全片时长的唯一对轴基准
          example: https://.../original-audio.mp3
        originalVocalUrl:
          type: string
          description: 与原唱同义字段，二选一提供即可
        bgmUrl:
          type: string
          description: 背景音乐（仅参考，MV 不使用伴奏混播）

    MvLyrics:
      type: object
      description: 歌词（可选）
      properties:
        lrcUrl:
          type: string
          description: 逐行歌词（LRC）
        wordLrcUrl:
          type: string
          description: 逐字歌词（用于卡拉OK字幕）

    MvTimeline:
      type: object
      required: [durationMs, shotCount]
      properties:
        frameRate:
          type: integer
          enum: [24, 25, 30, 50, 60]
          default: 30
          description: 成片帧率
        durationMs:
          type: integer
          description: 甲方声明的全片时长（毫秒）。必须等于 `Σ shots[].durationMs`（容差 ±200ms）；缺失或为 0 会被渲染前自检闸门拦截
          example: 122000
        audioDurationMs:
          type: integer
          description: 音频实测时长（毫秒）。建议提供 —— 平台对轴的首选基准（消解 durationMs 整秒取整带来的几百毫秒差异）
          example: 122376
        shotCount:
          type: integer
          description: 镜头数。必须 = `shots.length` = 包内分镜图张数，三者任一不符即被闸门拦截
          example: 23
        shotMaxSeconds:
          type: number
          default: 15
          description: 单镜时长上限（默认 15 秒），超过请拆成多个镜头

    MvShot:
      type: object
      required: [durationMs, videoPrompt]
      properties:
        segmentIndex:
          type: integer
          description: 段号（缺省 0）
        shotIndex:
          type: integer
          description: 镜号（缺省为数组下标）
        segmentType:
          type: string
          enum: [poster, intro, verse, chorus, bridge, interlude, outro, lyric, main]
          description: |
            镜头类型；intro / interlude / outro 视为空镜，不得引入人物主体（R4）。
            poster（2026-10-14 新增）= 甲方自带的海报分镜，必须位于 shots 首位（镜号 0/0），
            渲染为独立静音前置段：海报段期间无歌声、无歌词字幕，歌曲音频/歌词由平台自动整体后移海报时长；
            此时 timeline.durationMs 与 shotCount 必须包含海报镜（成片总时长 = 海报段 + 音频实测）。
            平台检测到 poster 镜后不再自动插入海报分镜。
        lyricLine:
          type: string
          description: 该镜对应歌词（可空）
        imageUrl:
          type: string
          description: 图生视频的输入底图（该镜的起始帧）；与包内 `shot/NNN.jpg` 同源同内容，包内图优先
        endImageUrl:
          type: string
          description: |
            【可选 · 2026-10-01 新增】图生视频的**尾帧**底图，与 `imageUrl` 配对构成**首尾帧生成**，用于锚定本镜的结束画面（相邻镜头因此能接得上）。

            ★ 自适应：**提供本字段才走首尾帧模式**；不提供则完全按现行单首帧管线生成，行为与改造前一致、**不受任何影响**。

            包内若单独提供尾帧，请放在 `end/` · `shot-end/` · `lastframe/` 目录（按镜号取值，与 `shot/` **严格分开**，
            否则尾帧文件也常被命名为 `NNN.jpg`，混入首帧映射会导致首帧被尾帧覆盖）。
        videoPrompt:
          type: string
          description: ★ 该镜画面怎么动（人物动作 + 环境变化）。必填：决定画面内容
        camera:
          type: string
          description: "运镜：push / pull / orbit / pan / tilt / static / front / side / closeup（也接受中文，平台自动归一化）"
        stage:
          type: string
          description: 归属段落（可选），如 intro / main / outro
        startMs:
          type: integer
          description: 该镜在音频中的起点（毫秒，建议提供以便逐镜对账）
        endMs:
          type: integer
          description: 该镜在音频中的终点（毫秒）。`max(endMs)` 必须 ≤ 音频实测时长，越界会被闸门拦截
        durationMs:
          type: integer
          description: ★ 该镜时长（毫秒）。「每镜在成片里占多久」的唯一依据；≤ `shotMaxSeconds`
          example: 8857

    MvDelivery:
      type: object
      description: 交付规格（分辨率 / 帧率 / 调色 / 水印 / 片头片尾）
      properties:
        aspectRatio:
          type: string
          example: "16:9"
        resolution:
          type: string
          default: 1920x1080
          description: 成片分辨率（等比缩放居中补边，不拉伸变形）
        frameRate:
          type: integer
          default: 30
        lutCode:
          type: string
          description: 全片调色 LUT 编号（缺文件时平台自动降级跳过并记日志）
        watermarkUrl:
          type: string
        watermarkPosition:
          type: string
          example: bottom-right
        introEnabled:
          type: boolean
        outroUrl:
          type: string
          description: 片尾素材地址（图片或视频均可）

    MvProviderReturn:
      type: object
      description: 成片回传票据与通道
      properties:
        returnToken:
          type: string
          description: 回传票据（7 天有效），平台调用 `returns/*` 时放在 `X-MV-Return-Token` 头
          example: rt-xxx
        completeEndpoint:
          type: string
          description: 回传完成确认接口路径
        allowedContentType:
          type: string
          default: video/mp4
        maxFileSizeBytes:
          type: integer
          default: 104857600
          description: 允许的最大成片体积（默认 100MB）

    MvMaterialResponse:
      type: object
      description: |
        物料包下发（供应商实现）。**硬性要求**（缺一项即被打回，平台不猜测、不兜底）：
        `mv.timeline.durationMs`、`mv.timeline.shotCount`、`mv.shots[]`、`shots[].durationMs`、`shots[].videoPrompt`、
        `media.vocalUrl`（或 `originalVocalUrl`）。
        **MV 一致性核验链（2026-10-03 新增，硬性）**：核验单覆盖完整链条 ——
        **MV 脚本 → 主角定妆照 → MV 镜头表（含首帧）→ 镜头首尾帧**，四环任一不通过即打回 `awaiting_materials`
        （不消耗算力、不产出成片），打回备注以「【素材包不予受理，请整改后重新推送】」开头逐条列明。
        注意：**定妆照已设为 required**（`mv.dressAvailable` / `mv.dressImageUrl`），**未定妆的作品推送接单会被拦下**；
        `mv.tailFrameReadyCount` 会与包内实际匹配到的尾帧数做交叉核验。详见文档 §15.4.1。
        提交前建议用 `docs/validate-mv-shots.mjs` 本地自查（与平台自检闸门同口径）。
      required: [ready, mv, media]
      properties:
        ready:
          type: boolean
          description: 物料是否就绪；false 时平台记录待就绪并按策略重试（不会用其它素材顶替）
        work:
          type: object
          properties:
            id:
              oneOf: [{ type: string }, { type: integer }]
            title:
              type: string
            artist:
              type: string
            durationSeconds:
              type: number
        media:
          $ref: "#/components/schemas/MvMedia"
        lyrics:
          $ref: "#/components/schemas/MvLyrics"
        images:
          type: object
          description: 相册/封面等基础图片（MV 以分镜图为主要素材）
          additionalProperties: true
        mv:
          type: object
          required: [shots]
          properties:
            timeline:
              $ref: "#/components/schemas/MvTimeline"
            dressImageUrl:
              type: string
              description: |
                主角**定妆照**地址（甲方契约新增，2026-10-03）。包内给图时平台会转存并替换为可下载地址（`cos://` / https）。
                **用途：换脸依据** —— 是否需要换脸由 `faceSwap` 开关决定（换脸就用、不换脸就不用）。
                **核验链第二环**：与 `dressAvailable` 任一就绪即视为已定妆。
              example: cos://bucket/mv-delivery/WORKID/dress.jpg
            faceSwap:
              type: boolean
              description: |
                **换脸开关**（甲方提供）：`true` = 用定妆照做换脸；`false` = 不换脸（不使用定妆照）；未提供 = 未声明。
                平台已按「多别名 + 宽松取值」接住（`faceSwap` / `face_swap` / `enableFaceSwap` / `useDressImage` / `dressSwap`；
                取值兼容布尔与 `on/off`、`启用/不启用`、`是/否`）。
                注意：核验链**不受本开关影响** —— 不论是否换脸，主角定妆照都必须是就绪状态。
              example: false
            dressAvailable:
              type: boolean
              description: |
                定妆照可用性（甲方契约新增）。`true` = 已定妆（可接单）；**缺失或 false 且无可下载地址 → 未定妆，
                推送接单会被核验闸门拦下**（打回 awaiting_materials + 提示先生成定妆照）。已定妆的作品不受影响。
              example: true
            tailFrameReadyCount:
              type: integer
              description: |
                **尾帧就绪数**（甲方契约新增），与 `shots[].endImageUrl` 对应。平台据此做**交叉核验**：
                与包内实际匹配到的尾帧数不一致 → 打回（堵住「声明有尾帧、包里缺图」的漏网）。
              example: 15
            tailFrameReady:
              type: integer
              description: 平台实测匹配到的尾帧数（供双方核对，应等于 `tailFrameReadyCount`）
              example: 15
            shots:
              type: array
              minItems: 1
              description: 镜头表。数组顺序即成片顺序（按 segmentIndex → shotIndex）；平台不增、不减、不重排
              items:
                $ref: "#/components/schemas/MvShot"
        delivery:
          $ref: "#/components/schemas/MvDelivery"
        providerReturn:
          $ref: "#/components/schemas/MvProviderReturn"
        materialPackage:
          type: object
          description: 成片上传通道
          properties:
            result_upload:
              type: object
              properties:
                url:
                  type: string
                  description: 预签名 PUT 地址（平台把 MP4 直传到这里）
                object_key:
                  type: string
                  description: 对象键，回传 complete 时回填
        providerReturnUnsigned:
          type: boolean
          description: 预留：无签名回传标记

    MvCompleteRequest:
      type: object
      required: [objectKey, sha256, bytes]
      properties:
        objectKey:
          type: string
          description: 成片对象键（与 `materialPackage.result_upload.object_key` 一致）
          example: temp/mv-video/757/output/final.mp4
        sha256:
          type: string
          description: 成片 SHA-256（64 位小写 hex，与实际文件逐字节一致）
        bytes:
          type: integer
          description: 成片字节数
          example: 58741233
        eventId:
          type: string
          description: 幂等 ID（重复提交返回 idempotent）
          example: evt-20260924-0001
