Skill: jiuping520/baiyin-video-skill (version 1.0.3)

--- name: baiyin-video-skill description: 通过百音开放平台生成 AI 视频。当用户想生成视频、图片转视频、查询视频任务状态时使用，即使未明确说"百音"但上下文涉及视频生成能力也应触发。 allowed-tools: Bash Read version: "1.0.1" --- > **CRITICAL — 版本门禁（MUST 在一切操作之前执行）** > > 在执行本 skill 的任何业务逻辑之前，**必须先完成以下版本检查**，不得跳过、延后或省略： > > 1. 读取本地 `_meta.json`，取得 `ownerId`、`slug`、`version`、`publishedAt`。 > 2. 请求 SkillHub 远程版本信息，比较 `publishedAt` 和 `version`。 > 3. 若远程版本更新 → 先更新本地 skill，再继续当前任务。 > 4. 若远程无更新或查询失败 → 静默继续，不提示用户。 > > **DO NOT** 跳过此步骤直接处理用户请求。即使用户的请求很紧急，也必须先完成版本检查。 > 版本检查通常只需几秒，不会影响用户体验。 # 百音视频生成 职责：**获取模型** → **选模型** → **选类型与参数** → **收集媒体** → **收集提示词** → **确认摘要** → **提交任务**。不要在用户看不到最终参数集的情况下静默提交。 **步骤必须严格按编号顺序执行，不允许跳步或调换。** 提示词（第 5 步）必须在类型与参数（第 3 步）之后收集，不能提前。 ## 运行时配置 - 静默读取环境变量 `BAIYIN_API_KEY`，仅在缺失时让用户提供。 ## 交互方式 根据平台能力选择交互方式： - **有 `AskUserQuestion`**（如 Claude Code）→ 使用结构化选择框，`header` 和 `question` 不要重复。多个维度可合并到同一个选择器（最多 4 个 question） - **无 `AskUserQuestion`**（如 Codex、Gemini CLI、OpenClaw）→ 降级为纯文本编号列表，**每个维度单独一轮交互**（先问生成类型 → 再问时长 → 再问分辨率 → 再问画幅），不要合并 后续步骤中提到"选择框"时，均指上述自适应交互，不再区分。 --- ## 第 1 步 - 获取可用模型 ```bash CACHE_FILE=$(bash <skill_dir>/scripts/fetch-config.sh) ``` 脚本自动处理缓存（1 小时有效）和 API 请求。**整个过程静默完成**，不向用户输出任何内容。 > 响应结构详见 `references/api-schema.md`。 --- ## 第 2 步 - 选择模型 ```bash bash <skill_dir>/scripts/parse-config.sh models # 输出: [{"model_code":"kling-video","model_name":"可灵视频3.0 Omni"},...] ``` - **1 个模型** → 自动预选，跳过 - **2-4 个模型** → 选择框（label 取 `model_name`，description 取 `model_code`） - **5+ 个模型** → 文本编号列表 + 自由输入 - 用户已指定模型 → 直接预选；未指定 → 必须让用户选择，不要推荐 --- ## 第 3 步 - 选择生成类型与参数 ```bash bash <skill_dir>/scripts/parse-config.sh model-detail <model_code> # 输出: { # "model_code": "kling-video", # "model_name": "可灵视频3.0 Omni", # "types": ["文生视频", "首尾帧"], # "options": { # "文生视频": {"duration":["5","10"], "resolution":["720","1080"], "widescreen":["16:9","9:16","1:1"]}, # "首尾帧": {"duration":["5","10"], "resolution":["720"], "widescreen":["16:9","9:16"]} # } # } ``` 一次调用返回生成类型 + 每种类型的参数选项。用户选择类型后，直接从输出中读取对应参数。 按交互方式规则展示选择（有选择器时合并维度，纯文本时逐个问）： - 生成类型必须列出 `types` 数组中的**所有**类型，不能只列一种 - 生成类型命中多种时**必须让用户选择，不允许跳过或自动预选** - 生成类型只命中 1 种 → 静默应用 - 某参数选项只有 1 个值 → 静默应用 - 某参数选项超过 4 个（如 `duration: ["1"..."15"]`）→ 选取 3-4 个常用值作为选项，用户可通过 "Other" 自定义 - **每个维度独立选择，不允许预组合**（如"文生视频，5秒，1080，16:9"是错误的） > 参数选项的静默应用、选择框规则等详见 `references/interaction-rules.md`。 用户选择后匹配方案： ```bash bash <skill_dir>/scripts/parse-config.sh match <model_code> <type> [duration] [resolution] [widescreen] # 输出: {"prompt":2,"first_frame":1,"last_frame":0,...} ``` > 选择框规则和示例见 `references/interaction-rules.md`。 --- ## 第 4 步 - 收集媒体输入 文生视频无需媒体，跳过。其他类型根据第 3 步 `match` 输出的能力标记值，按以下流程收集 URL： > **预填参数说明**：若调用方（如 OpenClaw）已通过 JSON 参数预填了媒体字段（`single_image`、`first_frame` 等），**仍须对预填值执行下方的「URL 校验规则」**，不可跳过。预填的本地路径必须先完成自动上传，替换为公网 URL，再继续后续步骤。 | 生成类型 | 收集流程 | |---|---| | 图生视频 | "请提供参考图片（URL 或本地路径）：" | | 首帧 | "请提供起始帧图片（URL 或本地路径）：" | | 首尾帧（`last_frame: 2`） | "请提供起始帧和结束帧图片（两张都必填，用换行/空格/逗号分隔）：" | | 首尾帧（`last_frame: 1`） | "请提供起始帧图片（如需结束帧，可用换行/空格/逗号分隔添加）：" | | 多图参考 | "请提供参考图片（最多 6 张，用换行/空格/逗号分隔）：" | | 音频驱动 | "请提供音频文件（URL 或本地路径）：" | | 视频参考 | "请提供参考视频（URL 或本地路径）：" | ### URL 校验规则 收到用户回复后按顺序校验： 1. **本地文件检测与自动上传** — 判断逻辑采用**反向检测**： - 若值以 `http://` 或 `https://` 开头 → 跳过，直接进入第 2 步格式校验 - 否则 → 一律视为本地路径，调用上传脚本： ```bash bash <skill_dir>/scripts/upload-file.sh <local_file_path> # 从返回 JSON 的 data.url 取公网地址 ``` 上传成功后自动替换为公网 URL 继续流程，无需用户再操作。上传失败则提示错误并重新索要。 2. **格式校验** — 最终 URL 必须以 `http://` 或 `https://` 开头 3. **数量校验** — 多图参考最多 6 张，超过则提示"最多支持 6 张"并重新索要 4. **跳过识别** — 可选媒体的"跳过"指令宽容匹配：`跳过` / `跳過` / `skip` / `无` / `没有` / `不用` / `不需要` / 空回复 均视为跳过 5. **校验失败重试** — 连续无效 3 次后，告知用户任务无法继续并退出 ### 其他规则 - 必填项不接受"跳过"，只能提供有效 URL - 首尾帧单次收集到的多个 URL，按分隔顺序分别赋值给 `first_frame` 和 `last_frame` - 不加"需公网可访问"等解释性内容 --- ## 第 5 步 - 收集提示词 - 方案 `prompt: 0` → 跳过，不收集提示词 - 方案 `prompt: 1` → 可选，主动询问用户是否需要添加提示词，可回复"跳过" - 方案 `prompt: 2` → 必填，必须收集 - 用户已描述场景且足够具体 → 直接用作提示词 - 长度 ≤ 1000 字符 **提示词薄弱时**（如"海边日出"），结合已选模型 `desc` 的特点润色，**直接弹出选择框，前面不输出任何文字**。选项：使用原始版（description 放原始内容）/ 使用润色版（description 放润色内容）。 **提示词必须是独立的选择器，不和参数合并。** > 润色示例见 `references/interaction-rules.md`。 --- ## 第 6 步 - 展示配置摘要（必须） 用选择框展示摘要并请求确认（确认提交 / 修改参数 / 取消）： ```text 以下是本次视频生成的配置，请确认： 模型：可灵视频3.0 Omni 生成模式：图生视频（首帧） 提示词："海边黄昏，一个女孩在散步，电影感镜头" 起始帧：https://cdn.example.com/start.jpg 画面比例：16:9 分辨率：720P 时长：5 秒 ``` 多图参考示例： ```text 以下是本次视频生成的配置，请确认： 模型：可灵视频3.0 Omni 生成模式：多图参考 提示词："两个角色在森林里对话" 参考图： 1. https://cdn.example.com/ref1.jpg 2. https://cdn.example.com/ref2.jpg 3. https://cdn.example.com/ref3.jpg 画面比例：16:9 分辨率：1080P 时长：10 秒 ``` - 模型名称原样展示（不翻译、不改写） - 媒体文件直接显示 URL，**多图参考必须列出所有图片 URL**，不能只显示一张 - 不展示 `model_code` 等内部标识 - 用户确认前不进入第 7 步 --- ## 第 7 步 - 提交任务 ```bash bash <skill_dir>/scripts/submit-task.sh '{"model_code":"kling-video","prompt":"...","widescreen":"16:9","resolution":"1080","duration":5}' ``` - 省略未配置的可选字段，不发送空字符串或 `null` - 不发送方案标记为 `0` 的字段 - **脚本成功返回后绝对不要再次调用**（重试逻辑已内置） 提交成功后展示 `taskId` 和 `requestId`，告知正在排队，然后**自动进入第 8 步轮询**。 > 请求体示例见 `references/api-schema.md`。 --- ## 第 8 步 - 查询任务状态（自动轮询） 提交成功后自动开始轮询，**每 30 秒查询一次**，直到状态为 `succeeded` 或 `failed`。**超过 10 分钟仍未完成则停止轮询**，展示 `taskId` 供手动查询。 ```bash bash <skill_dir>/scripts/query-task.sh <taskId> ``` | `status` | 处理 | |---|---| | `queued` | 继续轮询 | | `processing` | 继续轮询 | | `succeeded` | 停止轮询，返回视频 URL | | `failed` | 停止轮询，返回错误信息，建议调整参数 | 不展示 `data.result.raw`。 > 轮询策略见 `references/error-and-polling.md`。 --- > 完整执行流程示例见 `references/example-flow.md`。 ## 回复规则 - **绝对不要**跳过确认摘要（第 6 步）— 视频生成消耗积分，5 秒确认远比重新生成划算 - **绝对不要**在 `status: succeeded` 之前声称视频已完成 - **绝对不要**向用户输出脚本的原始 JSON 返回值、内部推理或流程说明。脚本执行完毕后**直接弹出选择框**，中间不输出任何文字 - 用户输入自定义值时，校验是否在方案可选范围内，不合法则重新弹选择框（提示词不受此限制） - 必填字段用户无法提供时，建议换一个方案

path: _meta.json
content: ownerId: baiyin
slug: baiyin-video-skill
version: 1.0.1
publishedAt: 1776240370000

path: scripts/parse-config.sh
content: #!/usr/bin/env bash
# 解析视频配置 JSON（多运行时自动检测: node → python3 → jq）
# 用法:
#   bash parse-config.sh models                                    → 输出模型列表
#   bash parse-config.sh model-detail <model_code>                 → 输出生成类型 + 各类型参数选项（合并）
#   bash parse-config.sh match <model_code> <type> [dur] [res] [ws] → 匹配方案，返回能力标记

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"

COMMAND="${1:-}"
shift || true

CACHE_FILE="$(get_cache_file)"

if [[ ! -f "$CACHE_FILE" ]]; then
  echo "ERROR: cache file not found. Run fetch-config.sh first." >&2
  exit 1
fi

# --- 检测可用运行时 ---
RUNTIME=""
if command -v node &>/dev/null; then
  RUNTIME="node"
elif command -v python3 &>/dev/null; then
  RUNTIME="python3"
elif command -v python &>/dev/null; then
  RUNTIME="python"
elif command -v jq &>/dev/null; then
  RUNTIME="jq"
else
  echo "ERROR: 需要 node、python3 或 jq 中的至少一个来解析 JSON。请安装后重试。" >&2
  exit 1
fi

# ============================================================
# Node.js 实现
# ============================================================
run_node() {
  node -e "
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('$CACHE_FILE','utf-8')).data;
const cmd = '$COMMAND';
const args = $(printf '%s\n' "$@" | node -e "const l=[];require('readline').createInterface({input:process.stdin}).on('line',v=>l.push(v)).on('close',()=>console.log(JSON.stringify(l)))" 2>/dev/null || echo '[]');

function filterByType(children, type) {
  switch(type) {
    case '文生视频': return children.filter(c => c.prompt >= 1);
    case '图生视频': return children.filter(c => c.single_image >= 1);
    case '首帧': return children.filter(c => c.first_frame >= 1 && (!c.last_frame || c.last_frame === 0));
    case '首尾帧': return children.filter(c => c.first_frame >= 1 && c.last_frame >= 1);
    case '多图参考': return children.filter(c => c.multi_image >= 1);
    case '音频驱动': return children.filter(c => c.audio >= 1);
    case '视频参考': return children.filter(c => c.video >= 1);
    default: return children;
  }
}

function getOptions(children, type) {
  const plans = filterByType(children, type);
  const d=new Set(), r=new Set(), w=new Set();
  plans.forEach(c => {
    (c.duration||[]).forEach(v=>d.add(v));
    (c.resolution||[]).forEach(v=>r.add(v));
    (c.widescreen||[]).forEach(v=>w.add(v));
  });
  return {duration:[...d],resolution:[...r],widescreen:[...w]};
}

if (cmd === 'models') {
  console.log(JSON.stringify(data.map(m => ({model_code:m.model_code, model_name:m.model_name}))));
} else if (cmd === 'model-detail') {
  const model = data.find(m => m.model_code === args[0]);
  if (!model) { console.error('ERROR: model not found'); process.exit(1); }
  const types = {};
  model.children.forEach(c => {
    if (c.prompt >= 1) types['文生视频'] = true;
    if (c.single_image >= 1) types['图生视频'] = true;
    if (c.first_frame >= 1 && c.last_frame >= 1) types['首尾帧'] = true;
    else if (c.first_frame >= 1) types['首帧'] = true;
    if (c.multi_image >= 1) types['多图参考'] = true;
    if (c.audio >= 1) types['音频驱动'] = true;
    if (c.video >= 1) types['视频参考'] = true;
  });
  const typeList = Object.keys(types);
  const optionsPerType = {};
  typeList.forEach(t => { optionsPerType[t] = getOptions(model.children, t); });
  console.log(JSON.stringify({model_code:model.model_code, model_name:model.model_name, types:typeList, options:optionsPerType}));
} else if (cmd === 'match') {
  const model = data.find(m => m.model_code === args[0]);
  if (!model) { console.error('ERROR: model not found'); process.exit(1); }
  const plans = filterByType(model.children, args[1]);
  const matched = plans.find(c => {
    const durOk = !args[2] || (c.duration||[]).includes(String(args[2]));
    const resOk = !args[3] || (c.resolution||[]).includes(String(args[3]));
    const wsOk  = !args[4] || (c.widescreen||[]).includes(String(args[4]));
    return durOk && resOk && wsOk;
  });
  if (!matched) { console.error('ERROR: no matching plan'); process.exit(1); }
  console.log(JSON.stringify({prompt:matched.prompt,first_frame:matched.first_frame,last_frame:matched.last_frame,single_image:matched.single_image,multi_image:matched.multi_image,audio:matched.audio,video:matched.video}));
}
"
}

# ============================================================
# Python 实现
# ============================================================
run_python() {
  local PY="$1"
  $PY -c "
import json, sys

with open('$CACHE_FILE') as f:
    data = json.load(f)['data']

cmd = '$COMMAND'
args = $( printf '['; first=true; for a in "$@"; do $first && first=false || printf ','; printf '\"%s\"' "$a"; done; printf ']' )

def filter_by_type(children, t):
    m = {
        '文生视频': lambda c: c.get('prompt',0) >= 1,
        '图生视频': lambda c: c.get('single_image',0) is not None and c.get('single_image',0) >= 1,
        '首帧': lambda c: c.get('first_frame',0) >= 1 and (not c.get('last_frame') or c.get('last_frame',0) == 0),
        '首尾帧': lambda c: c.get('first_frame',0) >= 1 and c.get('last_frame',0) >= 1,
        '多图参考': lambda c: c.get('multi_image',0) >= 1,
        '音频驱动': lambda c: c.get('audio',0) >= 1,
        '视频参考': lambda c: c.get('video',0) >= 1,
    }
    return [c for c in children if m.get(t, lambda c: True)(c)]

def get_options(children, t):
    plans = filter_by_type(children, t)
    d,r,w = set(),set(),set()
    for c in plans:
        for v in c.get('duration',[]): d.add(v)
        for v in c.get('resolution',[]): r.add(v)
        for v in c.get('widescreen',[]): w.add(v)
    return {'duration':list(d),'resolution':list(r),'widescreen':list(w)}

if cmd == 'models':
    print(json.dumps([{'model_code':m['model_code'],'model_name':m['model_name']} for m in data], ensure_ascii=False))
elif cmd == 'model-detail':
    model = next((m for m in data if m['model_code'] == args[0]), None)
    if not model: print('ERROR: model not found', file=sys.stderr); sys.exit(1)
    types = set()
    for c in model['children']:
        if c.get('prompt',0) >= 1: types.add('文生视频')
        if c.get('single_image') is not None and c.get('single_image',0) >= 1: types.add('图生视频')
        if c.get('first_frame',0) >= 1 and c.get('last_frame',0) >= 1: types.add('首尾帧')
        elif c.get('first_frame',0) >= 1: types.add('首帧')
        if c.get('multi_image',0) >= 1: types.add('多图参考')
        if c.get('audio',0) >= 1: types.add('音频驱动')
        if c.get('video',0) >= 1: types.add('视频参考')
    type_list = list(types)
    options_per_type = {t: get_options(model['children'], t) for t in type_list}
    print(json.dumps({'model_code':model['model_code'],'model_name':model['model_name'],'types':type_list,'options':options_per_type}, ensure_ascii=False))
elif cmd == 'match':
    model = next((m for m in data if m['model_code'] == args[0]), None)
    if not model: print('ERROR: model not found', file=sys.stderr); sys.exit(1)
    plans = filter_by_type(model['children'], args[1])
    dur,res,ws = (args[2] if len(args)>2 else None), (args[3] if len(args)>3 else None), (args[4] if len(args)>4 else None)
    for c in plans:
        dur_ok = not dur or str(dur) in [str(v) for v in c.get('duration',[])]
        res_ok = not res or str(res) in [str(v) for v in c.get('resolution',[])]
        ws_ok  = not ws  or str(ws)  in [str(v) for v in c.get('widescreen',[])]
        if dur_ok and res_ok and ws_ok:
            print(json.dumps({k:c.get(k) for k in ['prompt','first_frame','last_frame','single_image','multi_image','audio','video']}))
            sys.exit(0)
    print('ERROR: no matching plan', file=sys.stderr); sys.exit(1)
"
}

# ============================================================
# jq 实现
# ============================================================
run_jq() {
  local JQ_TYPE_FILTER='
    def type_filter($t):
      if $t == "文生视频" then .prompt >= 1
      elif $t == "图生视频" then (.single_image // 0) >= 1
      elif $t == "首帧" then .first_frame >= 1 and ((.last_frame // 0) == 0)
      elif $t == "首尾帧" then .first_frame >= 1 and (.last_frame // 0) >= 1
      elif $t == "多图参考" then (.multi_image // 0) >= 1
      elif $t == "音频驱动" then (.audio // 0) >= 1
      elif $t == "视频参考" then (.video // 0) >= 1
      else true end;
  '

  case "$COMMAND" in
    models)
      jq -c '[.data[] | {model_code:.model_code, model_name:.model_name}]' "$CACHE_FILE"
      ;;
    model-detail)
      local mkey="$1"
      jq -c --arg mkey "$mkey" "$JQ_TYPE_FILTER"'
        .data[] | select(.model_code == $mkey) |
        . as $m |
        { model_code: .model_code, model_name: .model_name,
          types: [.children[] |
            (if .prompt >= 1 then "文生视频" else empty end),
            (if (.single_image // 0) >= 1 then "图生视频" else empty end),
            (if .first_frame >= 1 and (.last_frame // 0) >= 1 then "首尾帧"
             elif .first_frame >= 1 then "首帧" else empty end),
            (if (.multi_image // 0) >= 1 then "多图参考" else empty end),
            (if (.audio // 0) >= 1 then "音频驱动" else empty end),
            (if (.video // 0) >= 1 then "视频参考" else empty end)
          ] | unique,
          options: (
            [.children[] |
              (if .prompt >= 1 then "文生视频" else empty end),
              (if (.single_image // 0) >= 1 then "图生视频" else empty end),
              (if .first_frame >= 1 and (.last_frame // 0) >= 1 then "首尾帧"
               elif .first_frame >= 1 then "首帧" else empty end),
              (if (.multi_image // 0) >= 1 then "多图参考" else empty end),
              (if (.audio // 0) >= 1 then "音频驱动" else empty end),
              (if (.video // 0) >= 1 then "视频参考" else empty end)
            ] | unique | . as $types |
            reduce $types[] as $t ({}; . + {
              ($t): ($m.children | map(select(type_filter($t))) |
                { duration: [.[].duration[]?] | unique,
                  resolution: [.[].resolution[]?] | unique,
                  widescreen: [.[].widescreen[]?] | unique })
            })
          ) }' "$CACHE_FILE"
      ;;
    match)
      local mkey="$1" type="$2" dur="${3:-}" res="${4:-}" ws="${5:-}"
      jq -c --arg mkey "$mkey" --arg type "$type" --arg dur "$dur" --arg res "$res" --arg ws "$ws" "$JQ_TYPE_FILTER"'
        .data[] | select(.model_code == $mkey) | .children |
        map(select(type_filter($type))) |
        map(select(
          ($dur == "" or ([.duration[]?] | index($dur) != null)) and
          ($res == "" or ([.resolution[]?] | index($res) != null)) and
          ($ws  == "" or ([.widescreen[]?] | index($ws) != null))
        )) |
        first // error("no matching plan") |
        {prompt, first_frame, last_frame, single_image, multi_image, audio, video}' "$CACHE_FILE"
      ;;
    *)
      echo "Usage: bash parse-config.sh <models|model-detail|match> [args...]" >&2
      exit 1
      ;;
  esac
}

# --- 分发到对应运行时 ---
case "$RUNTIME" in
  node)    run_node "$@" ;;
  python3) run_python python3 "$@" ;;
  python)  run_python python "$@" ;;
  jq)      run_jq "$@" ;;
esac


path: scripts/common.sh
content: #!/usr/bin/env bash
# 公共函数：缓存路径、环境变量校验、curl 认证头

# 固定 BASE_URL，不从环境变量读取
BASE_URL="https://ai.hikoon.com"

# 缓存文件路径（可通过 BAIYIN_CACHE_FILE 覆盖）
get_cache_file() {
  if [[ -n "${BAIYIN_CACHE_FILE:-}" ]]; then
    echo "$BAIYIN_CACHE_FILE"
  elif [[ "$(uname)" == "MINGW"* ]] || [[ "$(uname)" == "CYGWIN"* ]]; then
    echo "${TEMP:-${TMP:-C:/Temp}}/baiyin_config.json"
  else
    echo "/tmp/baiyin_config.json"
  fi
}

# 校验必需的环境变量
check_env() {
  if [[ -z "${BAIYIN_API_KEY:-}" ]]; then
    echo "ERROR: 环境变量 BAIYIN_API_KEY 未设置" >&2
    exit 1
  fi
}

# 带认证的 curl（附加超时）
curl_auth() {
  curl -s --connect-timeout 10 --max-time 30 \
    -H "Authorization: Bearer ${BAIYIN_API_KEY}" \
    -H "Content-Type: application/json" \
    "$@"
}


path: scripts/fetch-config.sh
content: #!/usr/bin/env bash
# 获取视频配置（带缓存）
# 用法: bash fetch-config.sh
# 输出: 缓存文件路径
# 环境变量: BAIYIN_API_KEY
# 可选: BAIYIN_CACHE_FILE（自定义缓存路径）, BAIYIN_SKIP_CACHE=1（强制刷新）

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"

check_env

CACHE_FILE="$(get_cache_file)"
CACHE_MAX_AGE=3600

need_fetch=true
if [[ "${BAIYIN_SKIP_CACHE:-}" != "1" ]] && [[ -f "$CACHE_FILE" ]]; then
  # 验证缓存文件有效性
  if ! head -c 1 "$CACHE_FILE" | grep -q '{'; then
    need_fetch=true
  else
    if [[ "$(uname)" == "Darwin" ]]; then
      file_age=$(( $(date +%s) - $(stat -f %m "$CACHE_FILE") ))
    else
      file_age=$(( $(date +%s) - $(stat -c %Y "$CACHE_FILE") ))
    fi
    if (( file_age < CACHE_MAX_AGE )); then
      need_fetch=false
    fi
  fi
fi

if $need_fetch; then
  http_code=$(curl_auth -w "%{http_code}" -o "$CACHE_FILE" \
    "${BASE_URL}/api/open/v1/video/config")

  if [[ "$http_code" != "200" ]]; then
    rm -f "$CACHE_FILE"
    echo "ERROR: /video/config 返回 HTTP $http_code" >&2
    exit 1
  fi

  if ! head -c 1 "$CACHE_FILE" | grep -q '{'; then
    rm -f "$CACHE_FILE"
    echo "ERROR: 响应不是有效 JSON" >&2
    exit 1
  fi
fi

echo "$CACHE_FILE"


path: scripts/upload-file.sh
content: #!/usr/bin/env bash
# 上传本地文件到百音开放平台，返回公网 URL
# 用法: bash upload-file.sh <local_file_path>
# 环境变量: BAIYIN_API_KEY

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"

check_env

if [[ -z "${1:-}" ]]; then
  echo "ERROR: 请提供文件路径" >&2
  exit 1
fi

FILE_PATH="$1"

if [[ ! -f "$FILE_PATH" ]]; then
  echo "ERROR: 文件不存在: $FILE_PATH" >&2
  exit 1
fi

response=$(curl -s --connect-timeout 10 --max-time 120 \
  -H "Authorization: Bearer ${BAIYIN_API_KEY}" \
  -F "file=@${FILE_PATH}" \
  -w "\n%{http_code}" \
  "${BASE_URL}/api/open/v1/file/upload")

http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')

if [[ "$http_code" != "200" ]]; then
  echo "ERROR: 上传失败 HTTP $http_code" >&2
  echo "$body" >&2
  exit 1
fi

if ! echo "$body" | grep -q '"success"[[:space:]]*:[[:space:]]*true'; then
  echo "ERROR: 上传失败" >&2
  echo "$body" >&2
  exit 1
fi

echo "$body"


path: scripts/query-task.sh
content: #!/usr/bin/env bash
# 查询视频任务状态
# 用法: bash query-task.sh <taskId>
# 环境变量: BAIYIN_API_KEY

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"

check_env

if [[ -z "${1:-}" ]]; then
  echo "ERROR: 请提供 taskId 参数" >&2
  exit 1
fi

TASK_ID="$1"

response=$(curl_auth -w "\n%{http_code}" \
  "${BASE_URL}/api/open/v1/tasks/${TASK_ID}")

http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')

if [[ "$http_code" != "200" ]]; then
  echo "ERROR: 查询任务返回 HTTP $http_code" >&2
  echo "$body" >&2
  exit 1
fi

echo "$body"


path: scripts/submit-task.sh
content: #!/usr/bin/env bash
# 提交视频生成任务（失败最多重试 3 次，指数退避，4xx 不重试）
# 用法: bash submit-task.sh '<json_body>'
# 环境变量: BAIYIN_API_KEY

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/common.sh"

check_env

BODY="$1"
MAX_RETRIES=3
attempt=0

while (( attempt < MAX_RETRIES )); do
  attempt=$((attempt + 1))

  response=$(curl_auth -w "\n%{http_code}" \
    -X POST \
    -d "$BODY" \
    "${BASE_URL}/api/open/v1/video/generate")

  http_code=$(echo "$response" | tail -1)
  body=$(echo "$response" | sed '$d')

  # 成功：HTTP 200、success: true、且 data.taskId 存在
  if [[ "$http_code" == "200" ]] \
    && echo "$body" | grep -q '"success"[[:space:]]*:[[:space:]]*true' \
    && echo "$body" | grep -q '"taskId"'; then
    echo "$body"
    exit 0
  fi

  # 4xx 客户端错误不重试
  if [[ "$http_code" == 4* ]]; then
    echo "ERROR: 客户端错误 HTTP $http_code，不重试" >&2
    echo "$body" >&2
    exit 1
  fi

  # 最后一次仍失败
  if (( attempt >= MAX_RETRIES )); then
    echo "ERROR: $MAX_RETRIES 次尝试均失败，最后一次 HTTP $http_code" >&2
    echo "$body" >&2
    exit 1
  fi

  # 指数退避: 1s → 2s → 4s
  sleep $(( 2 ** (attempt - 1) ))
done


path: references/error-and-polling.md
content: # 错误处理与轮询策略

## 目录

- [错误处理](#错误处理)
- [轮询策略](#轮询策略)

---

## 错误处理

调用接口可能遇到以下错误，需要针对性处理：

| HTTP 状态 | 错误码 / 现象 | 含义 | 处理方式 |
|---|---|---|---|
| `401` | `INVALID_API_KEY` | `BAIYIN_API_KEY` 无效、被禁用或用户状态异常 | 停止流程，提示用户检查 `BAIYIN_API_KEY` 环境变量 |
| `400` | class-validator 校验失败 | `model_code` 缺失、媒体 URL 不合法，或参数组合不匹配 | 检查字段类型和参数组合，重新构造请求体 |
| `404` | `TASK_NOT_FOUND` | 查询任务时 `taskId` 不存在或不属于当前用户 | 确认 `taskId` 是否来自同一个 `BAIYIN_API_KEY` 的提交 |
| 其它 5xx | 后端内部错误 | 内部任务创建失败、模型下游异常 | 告知用户后端错误，建议稍后重试或换一个方案 |
| 任务 `status: failed` | `data.error` 非空 | 模型侧生成失败（内容违规、图片下载失败等） | 把 `data.error` 原文呈现给用户，并根据错误类型建议调整参数后重试 |

**原则：**
- 不要吞掉错误。把 `success: false` 的响应当作失败处理，不要继续提交后续请求。
- 不要把错误伪装成成功。任务 `failed` 时绝对不要告诉用户"视频生成完成"。
- 失败后，根据错误的性质决定是让用户重试还是修改参数，不要机械地原样重发。

---

## 轮询策略

提交成功后**自动开始轮询**，无需用户要求：

- 每 30 秒查询一次
- `queued` / `processing` → 继续轮询
- `succeeded` / `failed` → 停止轮询，返回结果
- 超过 10 分钟仍未完成 → 停止轮询，告知用户任务仍在处理，展示 `taskId` 供手动查询


path: references/interaction-rules.md
content: # 交互规则参考

## 目录

- [模型选择示例](#模型选择示例)
- [生成类型选择示例](#生成类型选择示例)
- [级联参数选择框示例](#级联参数选择框示例)
- [选择框规则汇总](#选择框规则汇总)
- [提示词润色交互](#提示词润色交互)
- [画幅自然语言映射](#画幅自然语言映射)
- [展示约束](#展示约束)

---

## 模型选择示例

### 情况 B：2-4 个模型

```text
AskUserQuestion with questions array:
  [
    {
      header: "选择模型",
      question: "选择视频模型",
      multiSelect: false,
      options: [
        // 从 /video/config 返回的 data 数组动态生成
        // 每个 option 的 label 取 model_name，description 取 model_code
        { label: data[0].model_name, description: data[0].model_code },
        { label: data[1].model_name, description: data[1].model_code },
        ...
      ]
    },
  ]
```

### 情况 C：5 个及以上模型

`AskUserQuestion` 最多 4 个选项，无法容纳。改为文本编号列表 + 自由输入：

```text
当前可用的视频模型如下，请回复编号或名称选择：

1. 可灵 视频模型 — 支持文生视频与图生视频
2. Vidu 视频模型 — 速度快，长提示词
3. PixVerse — 时长灵活（1-15s）
4. Veo 视频模型 — 高画质，支持 1080p
5. Wan 视频模型 — 风格化生成
6. ...

直接回复编号（如 "1"）或模型名（如 "可灵"）即可。
```

用户回复后，模糊匹配模型名称或精确匹配编号。匹配不到则提示重新选择。

---

## 生成类型选择示例

生成类型不是互斥的 — 必须遍历所有方案，把所有符合条件的类型都列出来。只命中 1 种时才静默应用，否则必须弹选择框。

```text
AskUserQuestion with questions array:
  [
    {
      header: "生成类型",
      question: "选择生成类型",
      multiSelect: false,
      options: [
        { label: "文生视频", description: "输入提示词，AI 生成视频" },
        { label: "图生视频", description: "提供一张图片作为起始帧" },
        { label: "首尾帧", description: "提供首帧（必须）和尾帧（可选），AI 生成过渡" }
        // 如果方案 last_frame = 0，则该选项改为：
        // { label: "首帧", description: "提供首帧图片，AI 生成视频" }
        // 根据实际支持的类型动态生成，必须列出所有命中的类型
      ]
    }
  ]
```

---

## 级联参数选择框示例

把同一模型下所有需要用户选择的选项数组尽量合并到一个 `AskUserQuestion` 调用中。**有合理推荐理由时**（用户暗示偏好、选项明显更通用、能降低成本），把推荐项放第一位并加 `(推荐)`。没有明确倾向时不标推荐。

```text
AskUserQuestion with questions array:
  [
    {
      header: "画面比例",
      question: "选择画面比例",
      multiSelect: false,
      options: [
        { label: "16:9", description: "横屏（适合宽屏/电脑）" },
        { label: "9:16", description: "竖屏（适合手机/短视频）" },
        { label: "1:1", description: "方形（适合朋友圈/社交平台）" }
      ]
    },
    {
      header: "视频时长",
      question: "选择视频时长",
      multiSelect: false,
      options: [
        { label: "5秒", description: "短片段，生成快、省积分" },
        { label: "10秒", description: "标准时长" },
        { label: "15秒", description: "长片段，适合完整叙事" }
      ]
      // 用户可通过自带的 "Other" 选项输入任意秒数，如 "3秒"、"8秒"
    }
  ]
```

---

## 选择框规则汇总

- 选项数组只有 1 个值时，静默应用，不需要用户选
- 选项数组有 2-4 个值时，直接放进 `AskUserQuestion` 选择框
- 选项数组超过 4 个值时（如 `duration: ["1","2",...,"15"]`），**从中选取 3-4 个最常用的值作为选项**，用户可通过自带的 "Other" 输入其他值。不要降级为纯文本输入
- 多个选项数组尽量合并到同一个 `AskUserQuestion`（最多 4 个 question），一次问完
- 如果需要用户选择的维度超过 4 个，分多轮 `AskUserQuestion` 连续收集，每轮最多 4 个问题。优先把最影响结果的维度（画面比例、时长）放在第一轮
- 提示词在第 5 步收集：足够具体时直接使用，薄弱时通过 `AskUserQuestion` 展示原始版/润色版选项
- **有限选项必须用选择框，绝对不要用纯文本列表让用户手动输入**

---

## 提示词润色交互

如果提示词明显太薄（例如"来个视频"、"海边日出"、"一个女孩"这种只有主题没有细节的），不要直接追问"能不能告诉我更多"，而是 **主动润色后直接弹出选择框**：

1. 根据用户给出的关键词 + 已选模型的能力，自动扩写出一段完整的提示词（补充场景细节、氛围、镜头语言等）
2. **直接弹出 `AskUserQuestion`，前面不要输出任何文字**（不要写"您的提示词比较简短"、"我帮您润色了一版"等）：

```text
AskUserQuestion:
  header: "提示词"
  question: "选择提示词版本"
  multiSelect: false
  options:
    - label: "使用原始版"
      description: "海边日出"
    - label: "使用润色版"
      description: "海边黄昏，金色阳光洒在沙滩上，一个女孩赤脚走在浪花边缘，电影感镜头缓慢推进，暖色调"
```

规则：
- 润色时结合上下文：如果用户提供了参考图，润色内容要和图片内容呼应
- 润色结果保持在 200 字符以内，不要过度堆砌
- 用户可通过自带的 "Other" 选项输入自定义提示词
- 如果用户的提示词本身就足够具体（有主体 + 场景 + 至少一个修饰词），跳过润色，直接进入下一步

---

## 画幅自然语言映射

- "竖屏" / "portrait" / "vertical" / "手机" → `9:16`
- "横屏" / "landscape" / "horizontal" → `16:9`
- "方形" / "square" / "朋友圈" → `1:1`

---

## 展示约束

### 不要向用户展示的内容

不要暴露或询问：供应商名称、`mode`、`model_name`、子模型代码、内部路由字段。这些由用户选择的参数组合决定，后端自动匹配方案。

**以下内部标识符不要出现在任何面向用户的文本中**（包括摘要、确认框、状态报告、错误提示）：
- `model_code` — 用模型名称代替
- `internalTaskId`、`capability` — 纯内部字段
- `prompt` 这个词本身 — 对用户统一称为"提示词"

### 需要展示给用户的标识符

- `taskId`（`task_xxx`）— 任务提交成功后展示
- `requestId`（`req_xxx`）— 任务提交成功后展示


path: references/example-flow.md
content: # 执行流程示例

以下是两个完整交互示例，展示正确的步骤顺序和交互方式。

---

## 示例 1：文生视频（可灵）

**用户输入：** "用可灵生成海边日出"

**第 1 步** — 静默执行 `fetch-config.sh`，无输出。

**第 2 步** — 用户已指定"可灵"，预选 model_code:"kling-video"，跳过模型选择。无输出。

**第 3 步** — 执行 `model-detail kling-video`，得到 types: ["文生视频","首尾帧","多图参考","视频参考"]。弹出选择器：

```
[选择器 1] 生成类型: 文生视频 / 首尾帧 / 多图参考 / 视频参考
[选择器 2] 时长: 5秒 / 10秒
[选择器 3] 分辨率: 720P / 1080P
[选择器 4] 画幅: 16:9 / 9:16 / 1:1
```

用户选择：文生视频、5秒、1080P、16:9。执行 `match kling-video 文生视频 5 1080 16:9`。

**第 4 步** — 文生视频无需媒体，跳过。

**第 5 步** — "海边日出"较薄弱，直接弹出润色选择器（无前置文字）：

```
[选择器] header: 提示词 / question: 选择提示词版本
  1. 使用原始版 — "海边日出"
  2. 使用润色版 — "金色朝阳从海平线缓缓升起，天空染成橙红色，波光粼粼的海面倒映着霞光，镜头缓缓推进"
```

用户选择润色版。

**第 6 步** — 弹出配置摘要：

```
模型：可灵视频3.0 Omni
生成模式：文生视频
提示词："金色朝阳从海平线缓缓升起..."
画面比例：16:9
分辨率：1080P
时长：5 秒

选项：确认提交 / 修改参数 / 取消
```

用户确认。

**第 7 步** — 执行 `submit-task.sh`，展示 taskId 和 requestId。

**第 8 步** — 每 30 秒轮询，直到 succeeded 返回视频 URL。

---

## 示例 2：首尾帧（PixVerse，last_frame: 1 可选）

**用户输入：** "帮我生成一个视频"

**第 1 步** — 静默执行 `fetch-config.sh`。

**第 2 步** — 未指定模型，6 个模型。输出编号列表：

```
1. 可灵视频3.0 Omni — 支持文生视频/首尾帧/多图参考/视频参考
2. PixVerse视频生成 — 文生视频/首尾帧，简单直接
3. Vidu视频Q3 — 速度快，长提示词
...
```

用户回复"2"。

**第 3 步** — 执行 `model-detail pixverse-video`，得到 types: ["文生视频","首尾帧"]。弹出选择器：

```
[选择器 1] 生成类型: 文生视频 / 首尾帧
[选择器 2] 时长: 5秒 / 8秒 / 10秒
[选择器 3] 分辨率: 360P / 540P / 720P / 1080P
[选择器 4] 画幅: 16:9 / 1:1 / 9:16
```

用户选择：首尾帧、5秒、720P、16:9。执行 `match pixverse-video 首尾帧 5 720 16:9`，输出含 `last_frame: 1`。

**第 4 步** — `last_frame: 1`（可选），输出：

```
请提供起始帧图片 URL（如需结束帧，可用换行/空格/逗号/分号分隔添加）：
```

用户回复：`https://example.com/start.jpg`（仅提供起始帧）。

**第 5 步** — 用户未描述场景（只说了"帮我生成一个视频"），主动询问提示词。用户回复"海边日出"，薄弱，弹出润色选择器。

**第 6 步** — 摘要确认。

**第 7 步** — 提交。

**第 8 步** — 轮询。

---

## 关键要点

- 第 1-2 步之间、第 2-3 步之间**不输出任何文字**
- 生成类型**必须展示所有可用类型**，不能只列一种
- 提示词在第 5 步收集，**绝不能**提前到第 3 步之前
- 媒体收集的 `last_frame` 值从 `match` 输出读取，不猜测


path: references/api-schema.md
content: # API 接口结构参考

## 目录

- [/video/config 响应结构](#videoconfig-响应结构)
- [能力标记详解](#能力标记详解)
- [方案匹配方式](#方案匹配方式)
- [DTO 字段参考](#dto-字段参考)
- [/video/generate 请求体示例](#videogenerate-请求体示例)
- [/tasks/{taskId} 响应结构](#taskstaskid-响应结构)

---

## /video/config 响应结构

```json
{
  "success": true,
  "data": [
    {
      "model_code": "kling-video",
      "model_name": "可灵视频3.0 Omni",
      "children": [
        {
          "prompt": 2,
          "first_frame": 1,
          "last_frame": 0,
          "multi_image": 0,
          "audio": 0,
          "video": 0,
          "duration": ["5", "10"],
          "resolution": ["720", "1080"],
          "widescreen": ["16:9", "9:16", "1:1"]
        }
      ]
    }
  ]
}
```

## 能力标记详解

每个 child 上的能力标记取值：
- `0` = 不支持（不要发送这个字段）
- `1` = 可选（用户可以提供，也可以不提供）
- `2` = 必填（必须提供，否则任务失败）

后端归一化为整数能力标记的字段：`prompt`、`first_frame`、`last_frame`、`multi_image`、`audio`、`video`。

**`last_frame` 的实际含义：**
- `last_frame: 0` → 该方案为**首帧**模式，不支持结束帧
- `last_frame: 1` → 该方案为**首尾帧**模式，结束帧可选
- `last_frame: 2` → 该方案为**首尾帧**模式，结束帧必填

**`single_image` 说明：**
`single_image` 是**单图参考模式**的能力标记，与 `first_frame`（首帧/首尾帧）是不同的生成类型。`/video/config` 当前不将其归一化为整数能力标记，解析脚本通过检测字段是否存在且 `≥ 1` 来判断是否支持该模式。提交时使用 DTO 的 `single_image` 字段传图片 URL。

child 对象中的非标记字段（`duration`、`widescreen`、`resolution`）是允许值数组，数组长度 > 1 时需要让用户选择，= 1 时静默应用。

---

## 方案匹配方式

后端通过 `model_code` + 用户选择的参数（`duration`、`resolution`、`widescreen` 等）自动匹配到对应的方案。不需要前端传递方案 ID。

- 如果同一模型下多个方案只是在时长 / 分辨率上不同，后端会根据用户选择的参数值匹配到正确的方案。
- 如果某个方案包含多值数组（例如 `duration: ["5","10","15"]`），用户选中的值作为参数发送即可。

---

## DTO 字段参考

| 字段 | 类型 | 用户可见名称 |
|---|---|---|
| `model_code` | string | （不展示） |
| `prompt` | string | **提示词** |
| `first_frame` | string | 起始帧 |
| `last_frame` | string | 结束帧 |
| `single_image` | string | 参考图 |
| `multi_image` | string[] | 参考图 |
| `duration` | number | 时长（第 3 步收集） |
| `resolution` | string | 分辨率（第 3 步收集） |
| `widescreen` | string | 画面比例（第 3 步收集） |
| `audio` | string | 音频 |
| `video` | string | 参考视频 |

---

## /video/generate 请求体示例

### 文生视频（最小请求体）

```json
{
  "model_code": "kling-video",
  "prompt": "海边黄昏，镜头缓慢推进",
  "widescreen": "16:9",
  "resolution": "1080",
  "duration": 5
}
```

### 图生视频

```json
{
  "model_code": "kling-video",
  "prompt": "海边黄昏，一个女孩在散步",
  "first_frame": "https://cdn.example.com/start.jpg",
  "widescreen": "16:9",
  "resolution": "720",
  "duration": 5
}
```

### 首尾帧

```json
{
  "model_code": "kling-video",
  "prompt": "从日出到黄昏的过渡",
  "first_frame": "https://cdn.example.com/start.jpg",
  "last_frame": "https://cdn.example.com/end.jpg",
  "resolution": "1080",
  "duration": 10
}
```

### 多图参考

```json
{
  "model_code": "kling-video",
  "prompt": "两个角色在森林里对话",
  "multi_image": [
    "https://cdn.example.com/ref1.jpg",
    "https://cdn.example.com/ref2.jpg"
  ],
  "widescreen": "16:9",
  "resolution": "1080"
}
```

---

## /tasks/{taskId} 响应结构

```json
{
  "success": true,
  "data": {
    "requestId": "req_xxx",
    "taskId": "task_xxx",
    "capability": "video.generate",
    "status": "processing",
    "result": {
      "taskId": 902,
      "internalTaskId": "hk_ai_task_xxx",
      "imageUrl": null,
      "images": [],
      "videoUrl": "https://cdn.example.com/video/result.mp4",
      "videos": ["https://cdn.example.com/video/result.mp4"],
      "progress": 85,
      "raw": [{ "video": "https://cdn.example.com/video/result.mp4" }]
    },
    "error": null,
    "billing": null
  }
}
```


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)