Skill: vennduan/next-video-gen (version 1.0.6)

--- name: next-video-gen description: 豆包 Next Video Gen AI 视频/图片生成 via 火山引擎方舟平台。支持文生图、文生视频、图生视频、素材生视频，集成 Seedance 系列模型。 homepage: https://github.com/vennduan/next-video-gen metadata: {"requires":{"bins":["node"],"env":["DOUBAO_API_KEY"]},"primaryEnv":"DOUBAO_API_KEY"} --- # 豆包 Next Video Gen 由 Seedance 系列模型驱动的 AI 内容生成助手，通过火山引擎方舟平台 API 工作。 ## 模型 | 模型 ID | 能力 | API 端点 | |---------|------|----------| | `doubao-seedream-5-0-260128` | 文生图 | `POST /v3/images/generations` | | `doubao-seedance-1-5-pro-251215` | 文生视频、图生视频、素材生视频（1.5 Pro） | `POST /v3/contents/generations/tasks` | | `doubao-seedance-2-0-260128` | 文生视频、图生视频、素材生视频（2.0） | `POST /v3/contents/generations/tasks` | **默认模型**: `doubao-seedance-1-5-pro-251215` ## 生成模式 | 模式 | 说明 | 必需参数 | |------|------|---------| | `txt2img` | 文生图 | prompt | | `txt2video` | 文生视频（默认无音频） | prompt, duration | | `img2video` | 图生视频 | prompt, image URL, duration | | `vid2video` | 素材生视频 | prompt, video URL, duration | ### 字幕处理 当前 API 暂无独立的字幕参数。如需字幕，可将字幕内容写入 prompt 中描述（如「底部有白色字幕：xxx」），模型会根据描述自行处理。此方式非原生支持，效果取决于模型理解能力。 ## 参数说明 ### 图片参数（txt2img） | 参数 | 默认值 | 可选值 | 说明 | |------|--------|--------|------| | `--quality` | `2K` | `2K`, `1K`, `HD` | 分辨率。2K ≈ 2048px | | `--aspect-ratio` | `1:1` | `1:1`, `16:9`, `9:16` | 宽高比 | | `--watermark` | `true` | `true`, `false` | 是否显示水印 | ### 视频参数（txt2video / img2video / vid2video） | 参数 | 默认值 | 可选值 | 说明 | |------|--------|--------|------| | `--duration` | `5` | `4`–`12` | 时长（秒） | | `--quality` | `720p` | `480p`, `720p`, `1080p` | 分辨率 | | `--aspect-ratio` | `16:9` | `16:9`, `9:16`, `1:1` | 宽高比 | | `--watermark` | `true` | `true`, `false` | 是否显示水印 | | `--enable-audio` | — | — | 启用音频（视频默认无音频） | | `--model` | `doubao-seedance-1-5-pro-251215` | 见模型表 | 指定模型 | ### 参考素材 | 参数 | 说明 | |------|------| | `--image <url>` | 参考图片（图生视频），可多次使用多张 | | `--video <url>` | 视频素材（素材生视频） | | `--audio <url>` | 参考音频（作为背景音乐） | ## 用法 ### Node.js 脚本（推荐，跨平台） ```bash # 设置 API key export DOUBAO_API_KEY=your_key_here # 文生图 node scripts/run-gen.js "一只橘猫在阳光下打盹" --mode txt2img # 文生图（指定分辨率和比例） node scripts/run-gen.js "未来城市夜景" --mode txt2img --quality 2K --aspect-ratio 16:9 # 文生视频（默认无音频） node scripts/run-gen.js "海上日落延时摄影" --mode txt2video # 文生视频（带音频） node scripts/run-gen.js "海上日落延时摄影，海浪轻拍" --mode txt2video --audio # 图生视频 node scripts/run-gen.js "镜头缓慢推进，猫咪转身" --mode img2video --image "https://example.com/cat.jpg" # 多张参考图生视频 node scripts/run-gen.js "从第一张平滑过渡到第二张" --mode img2video --image "https://example.com/1.jpg" --image "https://example.com/2.jpg" # 素材生视频 node scripts/run-gen.js "镜头加快，色彩更鲜艳" --mode vid2video --video "https://example.com/input.mp4" # 素材生视频 + 参考音频 node scripts/run-gen.js "节奏加快，配欢快背景音乐" --mode vid2video --video "https://example.com/input.mp4" --audio "https://example.com/bgm.mp3" # 指定模型（2.0） node scripts/run-gen.js "无人机航拍山谷" --mode txt2video --model doubao-seedance-2-0-260128 --duration 8 --quality 1080p # 竖屏视频 node scripts/run-gen.js "瀑布流淌" --mode txt2video --aspect-ratio 9:16 # 关闭水印 node scripts/run-gen.js "抽象艺术动画" --mode txt2video --watermark false ``` ### Bash 脚本（旧版，兼容） ```bash ./scripts/seedance-gen.sh "小猫在草地上奔跑" --mode txt2video ``` **依赖**: node（无 jq，无 curl） **输出目录**: `~/Videos/next-video-gen/`（可通过环境变量 `NEXT_VIDEO_GEN_OUTPUT_DIR` 配置） ## 脚本输出协议 解析以下结构化行并处理： | 行格式 | 何时出现 | 操作 | |--------|---------|------| | `TASK_SUBMITTED: task_id=<id> mode=<模式>` | 提交后 | 告诉用户任务已开始 | | `STATUS_UPDATE: <message>` | 每 30 秒 | 告诉用户进度 | | `IMAGE_URL=<url>` | 图片成功 | 展示 URL（链接短期有效，及时下载） | | `VIDEO_URL=<url>` | 视频成功 | 展示 URL（链接短期有效，及时下载） | | `ELAPSED=<Ns>` | 成功时 | 可提及耗时 | | `DURATION=<Xs>` | 视频成功 | 展示时长 | | `ASPECT_RATIO=<ratio>` | 成功时 | 展示比例 | | `RESOLUTION=<res>` | 成功时 | 展示分辨率 | | `HAS_AUDIO=<true\|false>` | 视频成功 | true 时告知"含音频" | | `LOCAL_FILE=<path>` | 成功时 | 告知本地保存路径 | | `ERROR: ...` | 失败时 | 展示错误信息 | ## 交付标准 生成成功后，完整呈现： **① 本地文件** — 告知保存路径 **② 规格** — 分辨率、宽高比、时长、是否含音频 **③ 在线链接** — 注明「链接有时效，及时下载」 **④ 耗时** — 可选 示例（视频）： ``` ✅ 视频生成完成！ 📁 本地文件：~/Videos/next-video-gen/video_20240412_153012.mp4 🎬 规格：5秒 · 720p · 16:9 · 无音频 🔗 在线链接（请尽快下载，短期内有效）：<VIDEO_URL> ⏱ 生成用时：47秒 ``` 示例（图片）： ``` ✅ 图片生成完成！ 📁 本地文件：~/Videos/next-video-gen/img_20240412_153012.png 🖼 规格：2K · 1:1 🔗 在线链接（请尽快下载，短期内有效）：<IMAGE_URL> ⏱ 生成用时：8秒 ``` > 注意：在线链接含临时签名，24 小时内有效，过期后需重新生成。 ## 错误处理 | 错误码 | 说明 | 用户操作 | |--------|------|---------| | `401` | API Key 无效 | 去控制台检查密钥 | | `403` | 权限不足 | 检查密钥的模型权限 | | `429` | 请求过频 | 稍后重试 | | `500`-`503` | 服务异常 | 稍后重试 | | `HTTP_ERROR:Client network socket disconnected` | Windows 环境 HTTPS 问题 | 重试，通常第二次成功 | ## 安装后 技能首次加载时检查 `DOUBAO_API_KEY`： - **已设置**: "准备好了！你想生成什么？" - **未设置**: "需要配置豆包 API 密钥才能使用。去火山引擎控制台获取一下吗？" 并引导配置。 ## 核心原则 1. **引导而非替用户决定** — 提供选项，让用户自己选 2. **用户驱动创意** — 用用户的描述，需要时提供建议 3. **智能上下文感知** — 只询问缺失部分 4. **意图优先** — 意图不明确时先确认 --- ## 参考 - 文生图模型: https://console.volcengine.com/ark/model_detail?Id=doubao-seedream-5-0-260128 - 视频 1.5 Pro: https://console.volcengine.com/ark/model_detail?Id=doubao-seedance-1-5-pro-251215 - 视频 2.0: https://console.volcengine.com/ark/model_detail?Id=doubao-seedance-2-0-260128

path: bin/cli.js
content: #!/usr/bin/env node

'use strict';

const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
const readline = require('readline');
const os = require('os');

// ── ANSI colors ──────────────────────────────────────────────────────────────
const green  = (s) => `\x1b[32m${s}\x1b[0m`;
const red    = (s) => `\x1b[31m${s}\x1b[0m`;
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
const blue   = (s) => `\x1b[34m${s}\x1b[0m`;
const bold   = (s) => `\x1b[1m${s}\x1b[0m`;
const cyan   = (s) => `\x1b[36m${s}\x1b[0m`;
const dim    = (s) => `\x1b[2m${s}\x1b[0m`;

// ── Package root (resolve relative to this script) ───────────────────────────
const PKG_ROOT = path.resolve(__dirname, '..');
const SKILL_SLUG = 'next-video-gen';

// ── Banner ────────────────────────────────────────────────────────────────────
function printBanner() {
  console.log('');
  console.log(bold(cyan('╔══════════════════════════════════════════════════════════╗')));
  console.log(bold(cyan('║') + '                                                          ' + bold(cyan('║'))));
  console.log(bold(cyan('║') + '   ' + bold('🎬  Next Video Gen Skill Installer') + '                    ' + bold(cyan('║'))));
  console.log(bold(cyan('║') + '       ' + dim('powered by Seedance 2.0 · Volcengine Ark') + '          ' + bold(cyan('║'))));
  console.log(bold(cyan('║') + '                  ' + dim('v1.0.0') + '                                   ' + bold(cyan('║'))));
  console.log(bold(cyan('║') + '                                                          ' + bold(cyan('║'))));
  console.log(bold(cyan('╚══════════════════════════════════════════════════════════╝')));
  console.log('');
}

// ── --help ────────────────────────────────────────────────────────────────────
function printHelp() {
  printBanner();
  console.log(bold('Usage:'));
  console.log('  node bin/cli.js            ' + dim('# interactive installer'));
  console.log('  node bin/cli.js -y         ' + dim('# non-interactive (for AI agents / CI)'));
  console.log('  node bin/cli.js -y --path <dir>  ' + dim('# install to specific directory'));
  console.log('  node bin/cli.js --help     ' + dim('# show this help'));
  console.log('  node bin/cli.js --version  ' + dim('# show version'));
  console.log('');
  console.log(bold('Options:'));
  console.log('  -y, --yes        ' + dim('Non-interactive mode. Auto-detect skills dir, skip prompts.'));
  console.log('  --path <dir>     ' + dim('Install to a specific directory (used with -y).'));
  console.log('');
  console.log(bold('What this installer does:'));
  console.log('  1. Detects your agent skills directory');
  console.log('  2. Copies skill files (SKILL.md, scripts/, references/)');
  console.log('  3. Checks required dependencies (jq, curl)');
  console.log('  4. Guides you through API key setup (skipped in -y mode)');
  console.log('');
  console.log(bold('Environment:'));
  console.log('  DOUBAO_API_KEY   ' + dim('Your Volcengine Ark API key (get one at https://console.volcengine.com/ark)'));
  console.log('');
}

// ── Helpers ───────────────────────────────────────────────────────────────────
function ask(rl, question) {
  return new Promise((resolve) => rl.question(question, resolve));
}

function commandExists(cmd) {
  try {
    const result = spawnSync(os.platform() === 'win32' ? 'where' : 'which', [cmd], {
      stdio: 'pipe',
      encoding: 'utf8',
    });
    return result.status === 0;
  } catch {
    return false;
  }
}

function tryExec(cmd) {
  try {
    return execSync(cmd, { stdio: 'pipe', encoding: 'utf8' }).trim();
  } catch {
    return null;
  }
}

function copyDir(src, dest) {
  if (!fs.existsSync(src)) return;
  fs.mkdirSync(dest, { recursive: true });
  // Node 16+: use cpSync if available, else manual recursion
  if (typeof fs.cpSync === 'function') {
    fs.cpSync(src, dest, { recursive: true });
  } else {
    // Fallback for Node 16 environments without cpSync (added in 16.7)
    _copyDirRecursive(src, dest);
  }
}

function _copyDirRecursive(src, dest) {
  fs.mkdirSync(dest, { recursive: true });
  const entries = fs.readdirSync(src, { withFileTypes: true });
  for (const entry of entries) {
    const srcPath = path.join(src, entry.name);
    const destPath = path.join(dest, entry.name);
    if (entry.isDirectory()) {
      _copyDirRecursive(srcPath, destPath);
    } else {
      fs.copyFileSync(srcPath, destPath);
    }
  }
}

function copyFile(src, dest) {
  if (!fs.existsSync(src)) return false;
  fs.mkdirSync(path.dirname(dest), { recursive: true });
  fs.copyFileSync(src, dest);
  return true;
}

// ── Step 1: Detect skills directory ──────────────────────────────────────────
async function detectSkillsDir(rl, opts = {}) {
  console.log(bold('\n[1/4] Detecting skills directory...'));

  const home = os.homedir();

  // If --path is given, use it directly
  if (opts.targetPath) {
    const resolved = opts.targetPath.replace(/^~/, home);
    console.log(green('  ✓ Using specified path: ') + resolved);
    return resolved;
  }

  const candidates = [
    path.join(home, '.openclaw', 'skills'),
    path.join(home, '.config', 'openclaw', 'skills'),
    path.join(home, 'Library', 'Application Support', 'openclaw', 'skills'), // macOS
    path.join(process.env.APPDATA || '', 'openclaw', 'skills'),              // Windows
  ].filter(Boolean);

  // Try openclaw CLI
  if (commandExists('openclaw')) {
    const cliPath = tryExec('openclaw skills path') ||
                    tryExec('openclaw config get skills_dir') ||
                    tryExec('openclaw --skills-dir');
    if (cliPath && fs.existsSync(path.dirname(cliPath))) {
      console.log(green('  ✓ Found via openclaw CLI: ') + cliPath);
      return cliPath;
    }
  }

  // Check known paths
  for (const candidate of candidates) {
    if (fs.existsSync(candidate)) {
      console.log(green('  ✓ Found: ') + candidate);
      return candidate;
    }
  }

  // Silent mode: create default path without asking
  if (opts.silent) {
    const defaultDir = path.join(home, '.openclaw', 'skills');
    console.log(yellow('  ⚠  No skills directory found, creating: ') + defaultDir);
    fs.mkdirSync(defaultDir, { recursive: true });
    return defaultDir;
  }

  // Not found — ask user
  console.log(yellow('  ⚠  Could not auto-detect skills directory.'));
  console.log(dim('  Common locations: ~/.openclaw/skills/  or  ~/.config/openclaw/skills/'));
  console.log('');

  const answer = await ask(
    rl,
    '  Enter skills directory path (or press Enter to install to current directory): '
  );

  if (answer.trim()) {
    const resolved = answer.trim().replace(/^~/, home);
    return resolved;
  }

  // Fallback: current directory
  const fallback = path.join(process.cwd(), 'skills');
  console.log(yellow(`  → Installing to: ${fallback}`));
  return fallback;
}

// ── Step 2: Copy skill files ──────────────────────────────────────────────────
async function copySkillFiles(skillsDir, rl, opts = {}) {
  console.log(bold('\n[2/4] Copying skill files...'));

  const destBase = path.join(skillsDir, SKILL_SLUG);

  // Confirm overwrite if already exists (skip in silent mode — always overwrite)
  if (fs.existsSync(destBase) && !opts.silent) {
    const answer = await ask(
      rl,
      yellow(`  ⚠  Skill already installed at ${destBase}\n  Overwrite? (y/N): `)
    );
    if (!answer.trim().toLowerCase().startsWith('y')) {
      console.log(yellow('  → Skipped. Existing installation preserved.'));
      return destBase;
    }
  }

  fs.mkdirSync(destBase, { recursive: true });

  let copied = 0;

  // SKILL.md
  if (copyFile(path.join(PKG_ROOT, 'SKILL.md'), path.join(destBase, 'SKILL.md'))) {
    console.log(green('  ✓ ') + 'SKILL.md');
    copied++;
  }

  // _meta.json
  if (copyFile(path.join(PKG_ROOT, '_meta.json'), path.join(destBase, '_meta.json'))) {
    console.log(green('  ✓ ') + '_meta.json');
    copied++;
  }

  // scripts/
  const scriptsSrc = path.join(PKG_ROOT, 'scripts');
  if (fs.existsSync(scriptsSrc)) {
    copyDir(scriptsSrc, path.join(destBase, 'scripts'));
    // Make shell scripts executable
    try {
      const scripts = fs.readdirSync(path.join(destBase, 'scripts'));
      for (const s of scripts) {
        if (s.endsWith('.sh')) {
          fs.chmodSync(path.join(destBase, 'scripts', s), 0o755);
        }
      }
    } catch { /* ignore chmod errors on Windows */ }
    console.log(green('  ✓ ') + 'scripts/');
    copied++;
  }

  // references/
  const refsSrc = path.join(PKG_ROOT, 'references');
  if (fs.existsSync(refsSrc)) {
    copyDir(refsSrc, path.join(destBase, 'references'));
    console.log(green('  ✓ ') + 'references/');
    copied++;
  }

  console.log(green(`\n  → ${copied} items installed to: ${destBase}`));
  return destBase;
}

// ── Step 3: Check dependencies ────────────────────────────────────────────────
function checkDependencies() {
  console.log(bold('\n[3/4] Checking dependencies...'));

  const deps = [
    {
      name: 'curl',
      required: true,
      installHint: {
        darwin: 'brew install curl  (or: xcode-select --install)',
        linux: 'sudo apt install curl  (or: sudo yum install curl)',
        win32: 'Download from https://curl.se/windows/',
      },
    },
    {
      name: 'jq',
      required: true,
      installHint: {
        darwin: 'brew install jq',
        linux: 'sudo apt install jq  (or: sudo yum install jq)',
        win32: 'choco install jq  (or download from https://stedolan.github.io/jq/)',
      },
    },
  ];

  let allOk = true;
  const platform = os.platform();

  for (const dep of deps) {
    if (commandExists(dep.name)) {
      console.log(green('  ✓ ') + dep.name + ' is available');
    } else {
      allOk = false;
      const hint = dep.installHint[platform] || dep.installHint.linux;
      console.log(yellow(`  ⚠  ${dep.name} not found`));
      console.log(dim(`     Install: ${hint}`));
    }
  }

  if (!allOk) {
    console.log(yellow('\n  → Some dependencies are missing. The skill requires jq and curl to run.'));
    console.log(dim('    Install them and you\'ll be good to go.'));
  } else {
    console.log(green('\n  → All dependencies satisfied.'));
  }
}

// ── Step 4: API key setup ─────────────────────────────────────────────────────
async function setupApiKey(rl) {
  console.log(bold('\n[4/4] Volcengine Ark API key setup...'));

  const existing = process.env.DOUBAO_API_KEY;
  if (existing) {
    const masked = existing.slice(0, 6) + '••••••••••••••••';
    console.log(green('  ✓ DOUBAO_API_KEY is already set: ') + masked);
    return;
  }

  console.log(yellow('  ⚠  DOUBAO_API_KEY is not set.'));
  console.log('');
  console.log('  To generate videos you need a Volcengine Ark API key.');
  console.log(bold('  → Get one at: ') + cyan('https://console.volcengine.com/ark'));
  console.log('');

  const answer = await ask(rl, '  Paste your API key here (or press Enter to skip): ');
  const key = answer.trim();

  if (!key) {
    console.log(yellow('  → Skipped. Set it later with:'));
    console.log(dim('    export DOUBAO_API_KEY=your_key_here'));
    return;
  }

  // Detect shell config file
  const shell = process.env.SHELL || '';
  let rcFile = path.join(os.homedir(), '.bashrc');
  if (shell.includes('zsh'))  rcFile = path.join(os.homedir(), '.zshrc');
  if (shell.includes('fish')) rcFile = path.join(os.homedir(), '.config', 'fish', 'config.fish');

  const exportLine = shell.includes('fish')
    ? `set -x DOUBAO_API_KEY "${key}"`
    : `export DOUBAO_API_KEY="${key}"`;

  const addToRc = await ask(
    rl,
    `  Add to ${path.basename(rcFile)}? (Y/n): `
  );

  if (!addToRc.trim().toLowerCase().startsWith('n')) {
    try {
      fs.appendFileSync(rcFile, `\n# Volcengine Ark API key (added by next-video-gen installer)\n${exportLine}\n`);
      console.log(green(`  ✓ Added to ${rcFile}`));
      console.log(dim(`    Run: source ${rcFile}  to activate in current shell`));
    } catch (err) {
      console.log(yellow(`  ⚠  Could not write to ${rcFile}: ${err.message}`));
      console.log(dim(`    Manually add: ${exportLine}`));
    }
  } else {
    console.log(dim(`  To activate later, run: ${exportLine}`));
  }
}

// ── Success summary ───────────────────────────────────────────────────────────
function printSuccess(installPath) {
  console.log('');
  console.log(bold(green('╔══════════════════════════════════════════════════════════╗')));
  console.log(bold(green('║') + '                                                          ' + bold(green('║'))));
  console.log(bold(green('║') + '   ' + bold('✓  Next Video Gen skill installed successfully!') + '        ' + bold(green('║'))));
  console.log(bold(green('║') + '                                                          ' + bold(green('║'))));
  console.log(bold(green('╚══════════════════════════════════════════════════════════╝')));
  console.log('');
  console.log(bold('Installed to:'));
  console.log('  ' + cyan(installPath));
  console.log('');
  console.log(bold('Next steps:'));
  console.log('  1. ' + dim('Ensure DOUBAO_API_KEY is set in your environment'));
  console.log('     ' + dim('export DOUBAO_API_KEY=your_key  (or add to .zshrc/.bashrc)'));
  console.log('  2. ' + dim('Load the skill in your agent and start generating videos'));
  console.log('     ' + dim('"Generate a 5-second 720p video of a sunset over the ocean"'));
  console.log('');
  console.log(dim('  Docs:    https://github.com/vennduan/next-video-gen'));
  console.log(dim('  API:     https://console.volcengine.com/ark'));
  console.log('');
}

// ── Parse --path argument ────────────────────────────────────────────────────
function getArgValue(args, flag) {
  const idx = args.indexOf(flag);
  if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
  return null;
}

// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
  const args = process.argv.slice(2);

  if (args.includes('--version') || args.includes('-v')) {
    console.log('1.0.0');
    process.exit(0);
  }

  if (args.includes('--help') || args.includes('-h')) {
    printHelp();
    process.exit(0);
  }

  const silent = args.includes('--yes') || args.includes('-y');
  const targetPath = getArgValue(args, '--path');

  printBanner();

  if (silent) {
    // Non-interactive mode — no readline, no prompts
    try {
      const opts = { silent: true, targetPath };
      const skillsDir = await detectSkillsDir(null, opts);
      const installPath = await copySkillFiles(skillsDir, null, opts);
      checkDependencies();

      // API key: just check and report, don't prompt
      console.log(bold('\n[4/4] Volcengine Ark API key setup...'));
      if (process.env.DOUBAO_API_KEY) {
        console.log(green('  ✓ DOUBAO_API_KEY is set.'));
      } else {
        console.log(yellow('  ⚠  DOUBAO_API_KEY is not set.'));
        console.log(dim('    Get one at: https://console.volcengine.com/ark'));
        console.log(dim('    Then run:   export DOUBAO_API_KEY=your_key'));
      }

      printSuccess(installPath);
    } catch (err) {
      console.error(red('\n  ✗ Installation failed: ') + err.message);
      process.exit(1);
    }
    return;
  }

  // Interactive mode
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  // Ensure readline closes cleanly on exit
  process.on('SIGINT', () => {
    console.log(yellow('\n\n  → Installation cancelled.'));
    rl.close();
    process.exit(1);
  });

  try {
    const opts = { silent: false, targetPath };
    const skillsDir = await detectSkillsDir(rl, opts);
    const installPath = await copySkillFiles(skillsDir, rl, opts);
    checkDependencies();
    await setupApiKey(rl);
    printSuccess(installPath);
  } catch (err) {
    console.error(red('\n  ✗ Installation failed: ') + err.message);
    process.exit(1);
  } finally {
    rl.close();
  }
}

main();


path: package.json
content: {
  "name": "next-video-gen",
  "version": "1.0.0",
  "description": "豆包 Next Video Gen — text-to-image, text-to-video, image-to-video, video-to-video with Seedance 2.0",
  "files": [
    "SKILL.md",
    "_meta.json",
    "scripts/",
    "references/",
    "LICENSE"
  ],
  "keywords": ["seedance", "video-generation", "image-generation", "ai-video", "doubao", "volcengine", "bytedance", "next-video-gen"],
  "author": "vennduan",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/vennduan/next-video-gen"
  },
  "homepage": "https://github.com/vennduan/next-video-gen",
  "engines": {
    "node": ">=16"
  }
}


path: README.md
content: # Next Video Gen

<p align="center">
  <strong>AI image & video generation via Volcengine Ark API — text-to-image, text-to-video, image-to-video, video-to-video.</strong>
</p>

<p align="center">
  <a href="#what-it-does">Features</a> •
  <a href="#installation">Install</a> •
  <a href="#getting-an-api-key">API Key</a>
</p>

<p align="center">
  <strong>Languages:</strong>
  <a href="README.md">English</a> |
  <a href="README.zh-CN.md">简体中文</a> |
  <a href="README.zh-TW.md">繁體中文</a>
</p>

---

## What It Does

A skill for AI coding agents (Claude Code, Cursor, etc.) that enables image and video generation via the Seedance 2.0 model on Volcengine Ark platform.

| Skill | Description | Model |
|-------|-------------|-------|
| **Next Video Gen** | Text-to-image, text-to-video, image-to-video, video-to-video | Seedance 2.0 |

---

## Installation

### Quick Install

```bash
openclaw skills add https://github.com/vennduan/next-video-gen
```

### Manual Install

```bash
git clone https://github.com/vennduan/next-video-gen.git
cd next-video-gen
openclaw skills add .
```

---

## Getting an API Key

1. Sign up at [console.volcengine.com/ark](https://console.volcengine.com/ark)
2. Go to API Keys and create a new key
3. Set it in your environment:

```bash
export DOUBAO_API_KEY=your_key_here
```

---

## What It Can Do

- **Text-to-image** — Describe a scene, get a high-quality image (PNG/JPEG/WebP)
- **Text-to-video** — Describe a scene, get a video (silent)
- **Text-to-video with audio** — Describe scene + sound, generates synchronized audio/video (default)
- **Image-to-video** — Animate an image with a text prompt (silent)
- **Image-to-video with audio** — Image + prompt + auto audio (default when image provided)
- **Video-to-video** — Provide a video + prompt, generate a new video based on the source material
- **Multiple resolutions** — Images: 2K / 1K / HD; Videos: 480p, 720p, 1080p
- **Flexible duration** — 4–12 seconds (videos)
- **Aspect ratios** — Images: 1:1 / 16:9 / 9:16; Videos: 16:9, 9:16, 1:1

### Usage Examples

Just talk to your agent:

> "Generate an image of a futuristic city at sunset"

> "Generate a 5-second video of a cat playing piano"

> "Create a cinematic sunset over the ocean, 720p, 16:9"

> "Use this image and animate it into an 8-second video"

> "Take this video and make it more vibrant with faster motion"

> "Generate a video with background music and bird sounds"

The agent guides you through any missing details and handles the generation.

### Requirements

- `curl` and `jq` installed on your system
- `DOUBAO_API_KEY` environment variable set

### Script Reference

The skill includes `scripts/seedance-gen.sh` for direct command-line use:

```bash
# Text-to-image
./scripts/seedance-gen.sh "A futuristic city at sunset, neon lights" --mode txt2img

# Text-to-image (large)
./scripts/seedance-gen.sh "Abstract art, vibrant colors" --mode txt2img --quality 2K

# Text-to-video with audio (default)
./scripts/seedance-gen.sh "A cat running through a sunny meadow, birds singing" --mode txt2video --duration 5

# Text-to-video without audio
./scripts/seedance-gen.sh "Ocean sunset timelapse" --mode txt2video --no-audio

# Image-to-video with audio (default)
./scripts/seedance-gen.sh "Camera slowly pans in, cat turns to look" --mode img2video --image "https://example.com/cat.jpg"

# Image-to-video without audio
./scripts/seedance-gen.sh "Animate the image" --mode img2video --image "https://example.com/img.jpg" --no-audio

# Video-to-video
./scripts/seedance-gen.sh "Make it more vibrant, faster motion" --mode vid2video --video "https://example.com/input.mp4"

# Specify resolution and duration
./scripts/seedance-gen.sh "Drone footage over a valley" --mode txt2video --duration 8 --quality 1080p

# Vertical format for social media
./scripts/seedance-gen.sh "Waterfall flowing" --mode txt2video --aspect-ratio 9:16

# Disable watermark
./scripts/seedance-gen.sh "Abstract art animation" --mode txt2video --watermark false
```

Output is saved to `~/Videos/next-video-gen/` (configurable via `NEXT_VIDEO_GEN_OUTPUT_DIR`).

### API Parameters

See [references/api-params.md](references/api-params.md) for complete API documentation.

---

## File Structure

```
.
├── README.md                    # This file
├── README.zh-CN.md              # Simplified Chinese
├── README.zh-TW.md              # Traditional Chinese
├── SKILL.md                     # Agent skill definition
├── _meta.json                   # Skill metadata
├── references/
│   └── api-params.md            # Complete API parameter reference
└── scripts/
    └── seedance-gen.sh          # Generation script
```

---

## Troubleshooting

| Issue | Solution |
|-------|----------|
| `jq: command not found` | Install jq: `apt install jq` / `brew install jq` |
| `401 Unauthorized` | Check your `DOUBAO_API_KEY` at [console.volcengine.com/ark](https://console.volcengine.com/ark) |
| `403 Forbidden` | Check API key permissions in the Volcengine console |
| `429 Too Many Requests` | Rate limited — wait and retry |
| `Content blocked` | Modify your prompt |
| Generation timeout | Videos can take 1–3 minutes, images are usually 5–15 seconds |

---

## License

MIT


path: README.zh-CN.md
content: # Next Video Gen 下一代视频生成

<p align="center">
  <strong>AI 图片/视频生成 via 火山引擎方舟平台 — 文生图、文生视频、图生视频、素材生视频，由 Seedance 2.0 驱动。</strong>
</p>

<p align="center">
  <a href="#功能介绍">功能</a> •
  <a href="#安装">安装</a> •
  <a href="#获取-api-key">API Key</a>
</p>

<p align="center">
  <strong>语言：</strong>
  <a href="README.md">English</a> |
  <a href="README.zh-CN.md">简体中文</a> |
  <a href="README.zh-TW.md">繁體中文</a>
</p>

---

## 功能介绍

适用于 AI 编程助手（Claude Code、Cursor 等）的图片/视频生成技能，基于火山引擎方舟平台的 Seedance 2.0 模型。

| 技能 | 描述 | 模型 |
|------|------|------|
| **Next Video Gen** | 文生图、文生视频、图生视频、素材生视频 | Seedance 2.0 |

---

## 安装

### 快速安装

```bash
openclaw skills add https://github.com/vennduan/next-video-gen
```

### 手动安装

```bash
git clone https://github.com/vennduan/next-video-gen.git
cd next-video-gen
openclaw skills add .
```

---

## 获取 API Key

1. 在 [console.volcengine.com/ark](https://console.volcengine.com/ark) 注册
2. 进入 API Keys 建立新密钥
3. 设置环境变量：

```bash
export DOUBAO_API_KEY=your_key_here
```

---

## 能力详情

- **文生图** — 文字描述生成高质量图片（PNG/JPEG/WebP）
- **文生视频** — 文字描述生成无声视频
- **文生视频（音画）** — 描述场景+声音，生成同步音视频（默认）
- **图生视频** — 图片 + 文字描述，生成无声视频
- **图生视频（音画）** — 图片 + 描述，生成带声音的视频（提供图片时默认）
- **素材生视频** — 视频 + 文字描述，基于已有视频素材生成新视频
- **多分辨率** — 图片：2K / 1K / HD；视频：480p / 720p / 1080p
- **弹性时长** — 视频 4–12 秒
- **多宽高比** — 图片：1:1 / 16:9 / 9:16；视频：16:9 / 9:16 / 1:1

### 使用示例

直接和代理对话：

> "生成一张未来城市日落的图片"

> "生成一个 5 秒的猫弹钢琴视频"

> "创建电影级海上日落画面，720p，16:9"

> "用这张图生成 8 秒的动画视频"

> "把这个视频处理成更鲜艳的色彩，加快节奏"

> "生成一个有欢快背景音乐和鸟叫声的视频"

代理会引导你补充缺失信息并处理生成。

### 系统需求

- 系统已安装 `curl` 和 `jq`
- 已设定 `DOUBAO_API_KEY` 环境变量

### 脚本参考

技能包含 `scripts/seedance-gen.sh` 供命令行直接使用：

```bash
# 文生图
./scripts/seedance-gen.sh "未来城市日落，霓虹灯闪烁" --mode txt2img

# 文生大图
./scripts/seedance-gen.sh "抽象艺术，鲜艳色彩" --mode txt2img --quality 2K

# 文生音画（默认，带声音）
./scripts/seedance-gen.sh "一只猫在草地上奔跑，阳光明媚，有鸟叫" --mode txt2video --duration 5

# 文生视频（无音频）
./scripts/seedance-gen.sh "海上日落延时摄影" --mode txt2video --no-audio

# 图生音画（默认，带声音）
./scripts/seedance-gen.sh "镜头缓慢推进，猫咪转身看向镜头" --mode img2video --image "https://example.com/cat.jpg"

# 图生视频（无音频）
./scripts/seedance-gen.sh "图片动起来" --mode img2video --image "https://example.com/img.jpg" --no-audio

# 素材生视频
./scripts/seedance-gen.sh "色彩更鲜艳，加快节奏" --mode vid2video --video "https://example.com/input.mp4"

# 指定分辨率和时长
./scripts/seedance-gen.sh "无人机航拍山谷" --mode txt2video --duration 8 --quality 1080p

# 竖屏视频
./scripts/seedance-gen.sh "瀑布流淌" --mode txt2video --aspect-ratio 9:16

# 关闭水印
./scripts/seedance-gen.sh "抽象艺术动画" --mode txt2video --watermark false
```

内容保存至 `~/Videos/next-video-gen/`（可透过 `NEXT_VIDEO_GEN_OUTPUT_DIR` 设定）。

### API 参数

完整 API 文档见 [references/api-params.md](references/api-params.md)。

---

## 文件结构

```
.
├── README.md                    # English
├── README.zh-CN.md              # 本文件（简体中文）
├── README.zh-TW.md              # 繁体中文
├── SKILL.md                     # 技能定义
├── _meta.json                   # 技能元数据
├── references/
│   └── api-params.md            # 完整 API 参数文档
└── scripts/
    └── seedance-gen.sh          # 生成脚本
```

---

## 常见问题

| 问题 | 解决方案 |
|------|---------|
| `jq: command not found` | 安装 jq：`apt install jq` / `brew install jq` |
| `401 Unauthorized` | 检查 `DOUBAO_API_KEY`，前往 [console.volcengine.com/ark](https://console.volcengine.com/ark) 确认 |
| `403 Forbidden` | 在火山引擎控制台检查 API 密钥权限 |
| `429 Too Many Requests` | 请求频率过高，稍等后重试 |
| 内容被拦截 | 修改提示词后重试 |
| 生成超时 | 视频通常需 1–3 分钟，图片通常 5–15 秒 |

---

## 授权条款

MIT


path: README.zh-TW.md
content: # Next Video Gen 下一代影片生成

<p align="center">
  <strong>AI 圖片/影片生成 via 火山引擎方舟平台 — 文生圖、文生影片、圖生影片、素材生影片，由 Seedance 2.0 驅動。</strong>
</p>

<p align="center">
  <a href="#功能介紹">功能</a> •
  <a href="#安裝">安裝</a> •
  <a href="#取得-api-key">API Key</a>
</p>

<p align="center">
  <strong>語言：</strong>
  <a href="README.md">English</a> |
  <a href="README.zh-CN.md">简体中文</a> |
  <a href="README.zh-TW.md">繁體中文</a>
</p>

---

## 功能介紹

適用於 AI 編程助手（Claude Code、Cursor 等）的圖片/影片生成技能，基於火山引擎方舟平台的 Seedance 2.0 模型。

| 技能 | 描述 | 模型 |
|------|------|------|
| **Next Video Gen** | 文生圖、文生影片、圖生影片、素材生影片 | Seedance 2.0 |

---

## 安裝

### 快速安裝

```bash
openclaw skills add https://github.com/vennduan/next-video-gen
```

### 手動安裝

```bash
git clone https://github.com/vennduan/next-video-gen.git
cd next-video-gen
openclaw skills add .
```

---

## 取得 API Key

1. 在 [console.volcengine.com/ark](https://console.volcengine.com/ark) 註冊
2. 進入 API Keys 建立新金鑰
3. 設定環境變數：

```bash
export DOUBAO_API_KEY=your_key_here
```

---

## 能力詳情

- **文生圖** — 文字描述生成高品質圖片（PNG/JPEG/WebP）
- **文生影片** — 文字描述生成無聲影片
- **文生影片（音畫）** — 描述場景+聲音，生成同步音視訊（預設）
- **圖生影片** — 圖片 + 文字描述，生成無聲影片
- **圖生影片（音畫）** — 圖片 + 描述，生成帶聲音的影片（提供圖片時預設）
- **素材生影片** — 影片 + 文字描述，基於已有影片素材生成新影片
- **多解析度** — 圖片：2K / 1K / HD；影片：480p / 720p / 1080p
- **彈性時長** — 影片 4–12 秒
- **多寬高比** — 圖片：1:1 / 16:9 / 9:16；影片：16:9 / 9:16 / 1:1

### 使用範例

直接和代理對話：

> 「生成一張未來城市日落的圖片」

> 「生成一個 5 秒的貓彈鋼琴影片」

> 「創建電影級海上日落畫面，720p，16:9」

> 「用這張圖生成 8 秒的動畫影片」

> 「把這個影片處理成更鮮艷的色彩，加快節奏」

> 「生成一個有歡快背景音樂和鳥叫聲的影片」

代理會引導你補充缺失資訊並處理生成。

### 系統需求

- 系統已安裝 `curl` 和 `jq`
- 已設定 `DOUBAO_API_KEY` 環境變數

### 腳本參考

技能包含 `scripts/seedance-gen.sh` 供命令列直接使用：

```bash
# 文生圖
./scripts/seedance-gen.sh "未來城市日落，霓虹燈閃爍" --mode txt2img

# 文生大圖
./scripts/seedance-gen.sh "抽象艺术，鲜艳色彩" --mode txt2img --quality 2K

# 文生音畫（預設，帶聲音）
./scripts/seedance-gen.sh "一隻貓在草地上奔跑，陽光明媚，有鳥叫" --mode txt2video --duration 5

# 文生影片（無音訊）
./scripts/seedance-gen.sh "海上日落縮時攝影" --mode txt2video --no-audio

# 圖生音畫（預設，帶聲音）
./scripts/seedance-gen.sh "鏡頭緩慢推進，貓咪轉身看向鏡頭" --mode img2video --image "https://example.com/cat.jpg"

# 圖生影片（無音訊）
./scripts/seedance-gen.sh "圖片動起來" --mode img2video --image "https://example.com/img.jpg" --no-audio

# 素材生影片
./scripts/seedance-gen.sh "色彩更鮮艷，加快節奏" --mode vid2video --video "https://example.com/input.mp4"

# 指定解析度和時長
./scripts/seedance-gen.sh "無人機航拍山谷" --mode txt2video --duration 8 --quality 1080p

# 豎版影片
./scripts/seedance-gen.sh "瀑布流淌" --mode txt2video --aspect-ratio 9:16

# 關閉浮水印
./scripts/seedance-gen.sh "抽象藝術動畫" --mode txt2video --watermark false
```

內容儲存至 `~/Videos/next-video-gen/`（可透過 `NEXT_VIDEO_GEN_OUTPUT_DIR` 設定）。

### API 參數

完整 API 文件見 [references/api-params.md](references/api-params.md)。

---

## 檔案結構

```
.
├── README.md                    # English
├── README.zh-CN.md              # 簡體中文
├── README.zh-TW.md              # 本文件（繁體中文）
├── SKILL.md                     # 技能定義
├── _meta.json                   # 技能元數據
├── references/
│   └── api-params.md            # 完整 API 參數文件
└── scripts/
    └── seedance-gen.sh          # 生成腳本
```

---

## 常見問題

| 問題 | 解決方案 |
|------|---------|
| `jq: command not found` | 安裝 jq：`apt install jq` / `brew install jq` |
| `401 Unauthorized` | 檢查 `DOUBAO_API_KEY`，前往 [console.volcengine.com/ark](https://console.volcengine.com/ark) 確認 |
| `403 Forbidden` | 在火山引擎主控台檢查 API 金鑰權限 |
| `429 Too Many Requests` | 請求頻率過高，稍等後重試 |
| 內容被攔截 | 修改提示詞後重試 |
| 生成逾時 | 影片通常需 1–3 分鐘，圖片通常 5–15 秒 |

---

## 授權條款

MIT


path: references/api-params.md
content: # Next Video Gen API 参数参考

本文档为豆包 Next Video Gen 生成服务的完整 API 参数参考，基于火山引擎方舟平台。

## API 端点

### 图片生成
```
POST https://ark.cn-beijing.volces.com/api/v3/images/generations
GET  https://ark.cn-beijing.volces.com/api/v3/images/generations/{task_id}
Authorization: Bearer {DOUBAO_API_KEY}
Content-Type: application/json
```

### 视频生成
```
POST https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks
GET  https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{task_id}
Authorization: Bearer {DOUBAO_API_KEY}
Content-Type: application/json
```

## 模型

| 模型 | API 端点 | 说明 |
|------|---------|------|
| `doubao-seedream-5-0-260128` | `/v3/images/generations` | 文生图 |
| `doubao-seedance-1-5-pro-251215` | `/v3/contents/generations/tasks` | 文生视频、图生视频、素材生视频（1.5 Pro） |
| `doubao-seedance-2-0-260128` | `/v3/contents/generations/tasks` | 文生视频、图生视频、素材生视频（2.0） |

---

## 图片 API（文生图）

### 请求体

```json
{
  "model": "doubao-seedream-5-0-260128",
  "prompt": "一只橘猫在阳光下打盹",
  "sequential_image_generation": "disabled",
  "response_format": "url",
  "size": "2K",
  "aspect_ratio": "1:1",
  "watermark": true,
  "stream": false
}
```

### 字段说明

| 字段 | 类型 | 默认值 | 可选值 | 说明 |
|------|------|--------|--------|------|
| `model` | string | — | `doubao-seedream-5-0-260128` | **必填**。模型名称 |
| `prompt` | string | — | 任意文字 | **必填**。图片描述 |
| `sequential_image_generation` | string | `disabled` | `disabled` | 固定为 `disabled` |
| `response_format` | string | `url` | `url` | 固定为 `url` |
| `size` | string | `2K` | `2K`, `1K`, `HD` | 图片分辨率。2K ≈ 2048px |
| `aspect_ratio` | string | `1:1` | `1:1`, `16:9`, `9:16` | 宽高比 |
| `watermark` | boolean | `true` | `true`, `false` | 是否显示水印 |
| `stream` | boolean | `false` | `false` | 固定为 `false` |

### 图片成功响应

```json
{
  "id": "img-xxx-abc123",
  "status": "succeeded",
  "data": [{
    "url": "https://cdn.example.com/image.png"
  }]
}
```

---

## 视频 API（文生视频 / 图生视频 / 素材生视频）

### 请求体

```json
{
  "model": "doubao-seedance-2-0-260128",
  "content": [
    {
      "type": "text",
      "text": "一只猫在草地上奔跑，阳光明媚，有鸟叫"
    },
    {
      "type": "image_url",
      "image_url": { "url": "https://example.com/cat.jpg" },
      "role": "reference_image"
    },
    {
      "type": "video_url",
      "video_url": { "url": "https://example.com/input.mp4" },
      "role": "reference_video"
    },
    {
      "type": "audio_url",
      "audio_url": { "url": "https://example.com/bgm.mp3" },
      "role": "reference_audio"
    }
  ],
  "generate_audio": true,
  "ratio": "16:9",
  "duration": 5,
  "watermark": true
}
```

### 字段说明

| 字段 | 类型 | 默认值 | 可选值 | 说明 |
|------|------|--------|--------|------|
| `model` | string | — | `doubao-seedance-2-0-260128` | **必填**。模型名称 |
| `content` | array | — | — | **必填**。内容数组 |
| `content[].type` | string | — | `text`, `image_url`, `video_url`, `audio_url` | 内容类型 |
| `content[].role` | string | — | `reference_image`, `reference_video`, `reference_audio` | 仅 image/video/audio 需要 |
| `generate_audio` | boolean | `true` | `true`, `false` | 是否生成同步音频（语音、音效、背景音乐） |
| `ratio` | string | `16:9` | `16:9`, `9:16`, `1:1` | 视频宽高比 |
| `duration` | integer | `5` | `4`–`12` | 视频时长（秒） |
| `watermark` | boolean | `true` | `true`, `false` | 是否显示水印 |

### content 说明

- **text**: 必填，描述想要生成的内容
- **image_url**: 可选，参考图片，最多支持多张，`role: "reference_image"`
- **video_url**: 可选，视频素材，`role: "reference_video"`
- **audio_url**: 可选，音频素材（作为背景音乐等），`role: "reference_audio"`

### 视频成功响应

```json
{
  "id": "task-xxx-abc123",
  "status": "succeeded",
  "content": {
    "video_url": "https://cdn.example.com/video.mp4"
  },
  "duration": 5,
  "ratio": "16:9",
  "resolution": "720p",
  "generate_audio": true
}
```

---

## 任务状态值

| 状态 | 说明 | 操作 |
|------|------|------|
| `pending` | 任务已排队 | 继续轮询 |
| `running` | 生成中 | 继续轮询 |
| `succeeded` | 内容已就绪 | 从响应中提取 URL |
| `failed` | 生成失败 | 检查 error 字段 |

---

## 错误码

| 状态码 | 说明 | 常见原因 | 解决方案 |
|--------|------|---------|---------|
| `200` | 成功 | — | 处理响应 |
| `400` | 请求错误 | 参数无效、内容被拦截、文件过大 | 检查参数和内容 |
| `401` | 未授权 | API Key 无效或缺失 | 检查 `DOUBAO_API_KEY`，前往 [console.volcengine.com/ark](https://console.volcengine.com/ark) |
| `403` | 权限不足 | API Key 无模型访问权限 | 检查控制台中 API Key 的权限设置 |
| `429` | 请求过频 | 超过速率限制 | 等待后重试 |
| `500` | 服务器错误 | 服务异常 | 稍后重试 |

### 常见错误情况

#### 内容被拦截（400）
- **触发**：真实人脸、违规内容
- **解决**：修改 prompt，避免受限内容

#### 文件过大（400）
- **触发**：图片超过 30MB，视频超过 100MB
- **解决**：压缩文件后重试

#### Key 无效（401）
- **解决**：前往 [console.volcengine.com/ark](https://console.volcengine.com/ark) 确认 Key 状态

---

## 轮询策略

`scripts/seedance-gen.sh` 采用的轮询逻辑：
- 每 **5 秒**轮询一次
- 每 **30 秒**向用户报告一次进度
- **超时时间**：10 分钟（600 秒）

### 典型生成时间
- **文生图**：5–15 秒
- **4–5 秒, 480p 视频**：20–45 秒
- **5–8 秒, 720p 视频**：30–90 秒
- **10–12 秒, 1080p 视频**：60–180 秒

---

## 输出内容

- **有效期**：生成后 24 小时
- **图片格式**：PNG / JPEG / WebP
- **视频格式**：MP4
- **音频**：视频默认包含同步音频（语音、音效、背景音乐）
- **本地保存路径**：`~/Videos/next-video-gen/`（可通过 `NEXT_VIDEO_GEN_OUTPUT_DIR` 配置）

---

## 提示词建议

- 描述具体（颜色、光线、动作）
- 避免真实人脸
- 使用电影化语言获得更好效果
- 可在 prompt 中直接描述声音效果，如"背景音乐欢快"、"有鸟叫"、"人物说话"

---

## 参考

- 文生图模型详情：[console.volcengine.com/ark](https://console.volcengine.com/ark/model_detail?Id=doubao-seedream-5-0-260128)
- 视频生成模型详情：[console.volcengine.com/ark](https://console.volcengine.com/ark/model_detail?Id=doubao-seedance-2-0-260128)
- 生成脚本：`scripts/seedance-gen.sh`


path: scripts/download-file.js
content: #!/usr/bin/env node
// Download file utility - replaces curl for file downloads
// Usage: node download-file.js <url> <filepath>
const fs = require('fs');
const https = require('https');
const http = require('http');
const { URL } = require('url');

const url = new URL(process.argv[2]);
const filepath = process.argv[3];

const transport = url.protocol === 'https:' ? https : http;

const req = transport.get(url, function(res) {
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
    // Handle redirect
    const redirectUrl = new URL(res.headers.location, url);
    const redirectTransport = redirectUrl.protocol === 'https:' ? https : http;
    redirectTransport.get(redirectUrl, function(res2) {
      const stream = fs.createWriteStream(filepath);
      res2.pipe(stream);
      stream.on('finish', function() {
        process.exit(0);
      });
    }).on('error', function(e) {
      console.error('DOWNLOAD_ERROR:' + e.message);
      process.exit(1);
    });
    return;
  }

  if (res.statusCode !== 200) {
    console.error('HTTP_ERROR: status=' + res.statusCode);
    process.exit(1);
  }

  const stream = fs.createWriteStream(filepath);
  res.pipe(stream);
  stream.on('finish', function() {
    process.exit(0);
  });
});

req.on('error', function(e) {
  console.error('DOWNLOAD_ERROR:' + e.message);
  process.exit(1);
});

req.setTimeout(300000, function() {
  console.error('TIMEOUT');
  req.destroy();
  process.exit(1);
});

path: scripts/http-request.js
content: #!/usr/bin/env node
// HTTP request utility - replaces curl for cross-platform compatibility
// Usage: node http-request.js <method> <url> [payload_file|-d <payload_json>]
const http = require('http');
const https = require('https');

const method = process.argv[2];
const url = new URL(process.argv[3]);
// Handle both: node http-request.js POST url -d '{"json"}'  OR  node http-request.js POST url < file
const payloadArg = process.argv[4];
let payload = null;

if (payloadArg === '-d' && process.argv[5]) {
  // Read from file path passed after -d
  const fs = require('fs');
  try {
    payload = fs.readFileSync(process.argv[5], 'utf8');
  } catch(e) {
    // Fall back to argv[5] as inline JSON
    payload = process.argv[5];
  }
} else if (payloadArg && payloadArg !== '-d') {
  const fs = require('fs');
  try {
    payload = fs.readFileSync(payloadArg, 'utf8');
  } catch(e) {
    payload = payloadArg;
  }
}

const options = {
  hostname: url.hostname,
  path: url.pathname + url.search,
  method: method,
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + process.env.DOUBAO_API_KEY,
  }
};

if (payload) {
  options.headers['Content-Length'] = Buffer.byteLength(payload);
}

const transport = url.protocol === 'https:' ? https : http;

const req = transport.request(options, function(res) {
  let body = '';
  res.on('data', function(chunk) { body += chunk; });
  res.on('end', function() {
    process.stdout.write(body + '\n' + res.statusCode);
  });
});

req.on('error', function(e) {
  console.error('HTTP_ERROR:' + e.message);
  process.exit(1);
});

req.setTimeout(60000, function() {
  console.error('TIMEOUT');
  req.destroy();
  process.exit(1);
});

if (payload) {
  req.write(payload);
}
req.end();

path: scripts/run-gen.js
content: #!/usr/bin/env node
// Next Video Gen - Pure Node.js implementation
// Usage: node run-gen.js "prompt" [options]
//   --mode txt2img|txt2video|img2video|vid2video
//   --image <url> --video <url> --audio <url>
//   --duration <4-12> --quality <480p|720p|1080p|2K|1K|HD>
//   --aspect-ratio <16:9|9:16|1:1> --watermark <true|false>
//   --model <model-id> --audio (enable audio)

const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const os = require('os');

// ── Config ─────────────────────────────────────────────────────────────────────
const IMAGE_API_BASE = 'https://ark.cn-beijing.volces.com/api/v3/images';
const VIDEO_API_BASE = 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks';
const IMAGE_MODEL = 'doubao-seedream-5-0-260128';
const VIDEO_MODEL = 'doubao-seedance-1-5-pro-251215';
const MAX_POLL_SECONDS = 600;
const POLL_INTERVAL = 5;
const PROGRESS_INTERVAL = 30;
const OUTPUT_DIR = process.env.NEXT_VIDEO_GEN_OUTPUT_DIR || path.join(os.homedir(), 'Videos', 'next-video-gen');

// ── Args ───────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
let MODE = 'txt2video';
let MODEL = '';
let DURATION = 5;
let QUALITY = '';
let ASPECT_RATIO = '';
let WATERMARK = 'true';
let GENERATE_AUDIO = 'false';
let VIDEO_URL = '';
let AUDIO_URL = '';
let REF_IMAGES = [];
let PROMPT = '';

let i = 0;
while (i < args.length) {
  switch (args[i]) {
    case '--mode': MODE = args[++i]; break;
    case '--prompt': PROMPT = args[++i]; break;
    case '--image': REF_IMAGES.push(args[++i]); break;
    case '--video': VIDEO_URL = args[++i]; break;
    case '--audio': AUDIO_URL = args[++i]; break;
    case '--duration': DURATION = parseInt(args[++i]); break;
    case '--quality': QUALITY = args[++i]; break;
    case '--aspect-ratio': ASPECT_RATIO = args[++i]; break;
    case '--watermark': WATERMARK = args[++i]; break;
    case '--model': MODEL = args[++i]; break;
    case '--no-audio': GENERATE_AUDIO = 'false'; break;
    case '--enable-audio': GENERATE_AUDIO = 'true'; break;
    default:
      if (!PROMPT) PROMPT = args[i];
      break;
  }
  i++;
}

// ── Validation ─────────────────────────────────────────────────────────────────
if (!process.env.DOUBAO_API_KEY) {
  console.error('ERROR: DOUBAO_API_KEY environment variable is required.');
  console.error('Get your API key from: https://console.volcengine.com/ark');
  console.error('Set it with: export DOUBAO_API_KEY=your_key_here');
  process.exit(1);
}
if (!PROMPT) {
  console.error('ERROR: Prompt required.');
  process.exit(1);
}

// Detect output mode
let OUTPUT_MODE = 'video';
if (MODE === 'txt2img') OUTPUT_MODE = 'image';

// Validate based on mode
if (OUTPUT_MODE === 'image') {
  QUALITY = QUALITY || '2K';
  ASPECT_RATIO = ASPECT_RATIO || '1:1';
  if (!['2K','1K','HD'].includes(QUALITY)) { console.error('Image quality: 2K/1K/HD'); process.exit(1); }
  if (!['1:1','16:9','9:16'].includes(ASPECT_RATIO)) { console.error('Image ratio: 1:1/16:9/9:16'); process.exit(1); }
} else {
  QUALITY = QUALITY || '720p';
  ASPECT_RATIO = ASPECT_RATIO || '16:9';
  if (!['480p','720p','1080p'].includes(QUALITY)) { console.error('Video quality: 480p/720p/1080p'); process.exit(1); }
  if (!['16:9','9:16','1:1'].includes(ASPECT_RATIO)) { console.error('Video ratio: 16:9/9:16/1:1'); process.exit(1); }
  if (VIDEO_URL && MODE === 'txt2video') MODE = 'vid2video';
}

// ── HTTP helper ────────────────────────────────────────────────────────────────
const agent = new https.Agent({ keepAlive: false });

function doRequest(method, url, payload) {
  return new Promise((resolve, reject) => {
    const parsedUrl = new URL(url);
    const transport = parsedUrl.protocol === 'https:' ? https : http;

    const options = {
      hostname: parsedUrl.hostname,
      path: parsedUrl.pathname + parsedUrl.search,
      method: method,
      agent: transport === https ? agent : undefined,
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + process.env.DOUBAO_API_KEY,
      }
    };

    if (payload) {
      options.headers['Content-Length'] = Buffer.byteLength(payload);
    }

    const req = transport.request(options, function(res) {
      let body = '';
      res.on('data', function(chunk) { body += chunk; });
      res.on('end', function() {
        resolve({ body, statusCode: res.statusCode });
      });
    });

    req.on('error', function(e) {
      reject(new Error('HTTP_ERROR:' + e.message));
    });

    req.setTimeout(60000, function() {
      req.destroy();
      reject(new Error('TIMEOUT'));
    });

    if (payload) req.write(payload);
    req.end();
  });
}

// ── JSON helpers ──────────────────────────────────────────────────────────────
function jsonGet(obj, path) {
  const parts = path.split('.').filter(Boolean);
  for (const p of parts) {
    if (obj == null) return '';
    const m = p.match(/^([^\[]+)\[(\d+)\]$/);
    if (m) obj = obj[m[1]][parseInt(m[2])];
    else obj = obj[p];
  }
  return obj == null ? '' : (typeof obj === 'string' ? obj : JSON.stringify(obj));
}

// ── Download helper ────────────────────────────────────────────────────────────
function downloadFile(url, filepath) {
  return new Promise((resolve, reject) => {
    const parsedUrl = new URL(url);
    const transport = parsedUrl.protocol === 'https:' ? https : http;

    const req = transport.get(parsedUrl, function(res) {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        const redirectUrl = new URL(res.headers.location, parsedUrl);
        const redirectTransport = redirectUrl.protocol === 'https:' ? https : http;
        redirectTransport.get(redirectUrl, function(res2) {
          const stream = fs.createWriteStream(filepath);
          res2.pipe(stream);
          stream.on('finish', () => resolve());
        }).on('error', reject);
        return;
      }
      if (res.statusCode !== 200) {
        reject(new Error('HTTP_ERROR: status=' + res.statusCode));
        return;
      }
      const stream = fs.createWriteStream(filepath);
      res.pipe(stream);
      stream.on('finish', () => resolve());
    });

    req.on('error', reject);
    req.setTimeout(300000, () => { req.destroy(); reject(new Error('TIMEOUT')); });
  });
}

// ── Build payloads ─────────────────────────────────────────────────────────────
function buildImagePayload() {
  return JSON.stringify({
    model: IMAGE_MODEL,
    prompt: PROMPT,
    sequential_image_generation: 'disabled',
    response_format: 'url',
    size: QUALITY,
    aspect_ratio: ASPECT_RATIO,
    watermark: WATERMARK === 'true',
    stream: false
  });
}

function buildVideoPayload() {
  const content = [{ type: 'text', text: PROMPT }];

  REF_IMAGES.forEach(img => {
    content.push({ type: 'image_url', image_url: { url: img }, role: 'reference_image' });
  });

  if (VIDEO_URL) {
    content.push({ type: 'video_url', video_url: { url: VIDEO_URL }, role: 'reference_video' });
  }
  if (AUDIO_URL) {
    content.push({ type: 'audio_url', audio_url: { url: AUDIO_URL }, role: 'reference_audio' });
  }

  return JSON.stringify({
    model: MODEL || VIDEO_MODEL,
    content: content,
    generate_audio: GENERATE_AUDIO === 'true',
    ratio: ASPECT_RATIO,
    duration: DURATION,
    watermark: WATERMARK === 'true'
  });
}

// ── Error handler ─────────────────────────────────────────────────────────────
function handleError(statusCode, body) {
  switch (statusCode) {
    case 401: console.error('ERROR: Invalid API key. https://console.volcengine.com/ark'); break;
    case 403: console.error('ERROR: Access forbidden. Check API key permissions.'); break;
    case 429: console.error('ERROR: Rate limit exceeded. Wait and retry.'); break;
    case 500: case 502: case 503: console.error('ERROR: Service error. Try again later.'); break;
    default: console.error('ERROR: API error (' + statusCode + '): ' + body); break;
  }
}

// ── Main ──────────────────────────────────────────────────────────────────────
async function main() {
  fs.mkdirSync(OUTPUT_DIR, { recursive: true });

  if (OUTPUT_MODE === 'image') {
    // Submit image (sync)
    const payload = buildImagePayload();
    console.log('INFO: Submitting image generation...');

    const { body, statusCode } = await doRequest('POST', IMAGE_API_BASE + '/generations', payload);
    if (statusCode !== 200) { handleError(statusCode, body); process.exit(1); }

    const obj = JSON.parse(body);
    const imageUrl = jsonGet(obj, 'data[0].url');
    if (!imageUrl) { console.error('ERROR: Failed to get image URL:', body); process.exit(1); }

    console.log('TASK_SUBMITTED: task_id=sync mode=文生图');
    console.log('IMAGE_URL=' + imageUrl);
    console.log('RESOLUTION=' + QUALITY);
    console.log('ASPECT_RATIO=' + ASPECT_RATIO);
    console.log('ELAPSED=0s');

    const ext = 'png';
    const filename = 'img_' + Date.now() + '.' + ext;
    const filepath = path.join(OUTPUT_DIR, filename);

    try {
      await downloadFile(imageUrl, filepath);
      console.log('INFO: Image saved to ' + filepath);
      console.log('LOCAL_FILE=' + filepath);
    } catch(e) {
      console.warn('WARNING: Image download failed, use online URL: ' + imageUrl);
    }

  } else {
    // Submit video (async)
    const payload = buildVideoPayload();
    const modeInfo = { txt2video: GENERATE_AUDIO === 'true' ? '文生音画' : '文生视频',
                       img2video: GENERATE_AUDIO === 'true' ? '图生音画' : '图生视频',
                       vid2video: '素材生视频' }[MODE] || MODE;

    console.log('INFO: Submitting video generation (' + modeInfo + ')...');

    const { body, statusCode } = await doRequest('POST', VIDEO_API_BASE, payload);
    if (statusCode !== 200) { handleError(statusCode, body); process.exit(1); }

    const obj = JSON.parse(body);
    const taskId = jsonGet(obj, 'id');
    if (!taskId) { console.error('ERROR: Failed to get task_id:', body); process.exit(1); }

    console.log('TASK_SUBMITTED: task_id=' + taskId + ' mode=' + modeInfo);

    // Poll
    const startTime = Date.now();
    let lastReport = -1;

    while (true) {
      const elapsed = Math.floor((Date.now() - startTime) / 1000);

      if (elapsed > MAX_POLL_SECONDS) {
        console.warn('WARNING: Poll timeout. Check: https://console.volcengine.com/ark');
        process.exit(1);
      }

      const bucket = Math.floor(elapsed / PROGRESS_INTERVAL);
      if (bucket > lastReport && elapsed >= PROGRESS_INTERVAL) {
        lastReport = bucket;
        console.log('STATUS_UPDATE: 视频生成中... (已等待 ' + elapsed + ' 秒)');
      }

      await new Promise(r => setTimeout(r, POLL_INTERVAL * 1000));

      const pollResp = await doRequest('GET', VIDEO_API_BASE + '/' + taskId, null);
      if (pollResp.statusCode !== 200) { handleError(pollResp.statusCode, pollResp.body); process.exit(1); }

      const pollObj = JSON.parse(pollResp.body);
      const status = pollObj.status;

      if (status === 'succeeded') {
        const videoUrl = jsonGet(pollObj, 'content.video_url');
        const duration = jsonGet(pollObj, 'duration');
        const ratio = jsonGet(pollObj, 'ratio');
        const resolution = jsonGet(pollObj, 'resolution');
        const hasAudio = jsonGet(pollObj, 'generate_audio');

        console.log('ELAPSED=' + elapsed + 's');
        if (duration) console.log('DURATION=' + duration + 's');
        if (ratio) console.log('ASPECT_RATIO=' + ratio);
        if (resolution) console.log('RESOLUTION=' + resolution);
        console.log('HAS_AUDIO=' + hasAudio);
        console.log('VIDEO_URL=' + videoUrl);

        const ext = 'mp4';
        const filename = 'video_' + Date.now() + '.' + ext;
        const filepath = path.join(OUTPUT_DIR, filename);

        try {
          await downloadFile(videoUrl, filepath);
          console.log('INFO: Video saved to ' + filepath);
          console.log('LOCAL_FILE=' + filepath);
        } catch(e) {
          console.warn('WARNING: Video download failed, use online URL: ' + videoUrl);
        }
        break;

      } else if (status === 'failed') {
        const err = jsonGet(pollObj, 'error') || jsonGet(pollObj, 'message') || 'Unknown';
        console.error('ERROR: Generation failed: ' + err);
        process.exit(1);
      }
      // running or pending - continue polling
    }
  }
}

main().catch(e => {
  console.error('ERROR:', e.message);
  process.exit(1);
});

path: scripts/seedance-gen.sh
content: #!/bin/bash

# Next Video Gen 生成脚本
# 支持: 文生图、文生视频/音画、图生视频/音画、素材生视频
# 依赖: bash + node (无 jq, 无 curl)

set -uo pipefail

# 脚本目录（用于 node 工具脚本）
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

readonly IMAGE_API_BASE="https://ark.cn-beijing.volces.com/api/v3/images"
readonly VIDEO_API_BASE="https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks"
readonly IMAGE_MODEL="doubao-seedream-5-0-260128"
readonly VIDEO_MODEL="doubao-seedance-1-5-pro-251215"
readonly MAX_POLL_SECONDS=600
readonly POLL_INTERVAL=5
readonly PROGRESS_INTERVAL=30

MODE="txt2video"
MODEL=""
DURATION=5
QUALITY=""
ASPECT_RATIO=""
WATERMARK="true"
GENERATE_AUDIO="false"
VIDEO_URL=""
AUDIO_URL=""
REF_IMAGES=()
PROMPT=""
GLOBAL_TASK_ID=""
OUTPUT_MODE="video"

RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
error() { echo -e "${RED}ERROR: $1${NC}" >&2; exit 1; }
info() { echo -e "${BLUE}INFO: $1${NC}"; }
success() { echo -e "${GREEN}SUCCESS: $1${NC}"; }
warn() { echo -e "${YELLOW}WARNING: $1${NC}"; }

# ── 依赖检查 ──────────────────────────────────────────────────────────────────
check_dependencies() {
    if ! command -v node &> /dev/null; then
        error "node is required. Install: https://nodejs.org"
    fi
}

check_api_key() {
    if [[ -z "${DOUBAO_API_KEY:-}" ]]; then
        error "DOUBAO_API_KEY environment variable is required.
Get your API key from: https://console.volcengine.com/ark
Set it with: export DOUBAO_API_KEY=your_key_here"
    fi
}

# ── 参数解析 ──────────────────────────────────────────────────────────────────
parse_args() {
    if [[ $# -eq 0 ]]; then
        error "Usage: $0 \"prompt\" [options]

支持的模式:
  --mode txt2img       文生图
  --mode txt2video     文生视频 / 文生音画 (默认)
  --mode img2video     图生视频 / 图生音画
  --mode vid2video     素材生视频

Options:
  --mode <mode>        生成模式 (默认: txt2video)
  --prompt <text>      生成内容描述
  --image <url>        参考图片 URL (可多次)
  --video <url>        视频素材 URL (素材生视频)
  --audio <url>        参考音频 URL
  --duration <4-12>    视频时长 (默认: 5)
  --quality            图片: 2K/1K/HD / 视频: 480p/720p/1080p
  --aspect-ratio       图片: 1:1/16:9/9:16 / 视频: 16:9/9:16/1:1
  --watermark <true|false> (默认: true)
  --no-audio           关闭视频音频（音频默认关闭）
  --audio             启用视频音频（与 --audio <url> 参考音频不同）
  --model <id>         指定模型 (默认: doubao-seedance-1-5-pro-251215)
                        支持: doubao-seedance-1-5-pro-251215 / doubao-seedance-2-0-260128

Examples:
  \$0 \"一只橘猫在阳光下打盹\"
  \$0 \"未来城市夜景\" --mode txt2img --quality 2K
  \$0 \"猫在草地上奔跑，有鸟叫\" --mode txt2video --duration 5
  \$0 \"镜头推进\" --mode img2video --image \"https://example.com/cat.jpg\"
  \$0 \"加快节奏\" --mode vid2video --video \"https://example.com/input.mp4\""
  $0 "海边日落" --model doubao-seedance-2-0-260128 --duration 8
    fi

    while [[ $# -gt 0 ]]; do
        case $1 in
            --mode) MODE="$2"; shift 2 ;;
            --prompt) PROMPT="$2"; shift 2 ;;
            --image) REF_IMAGES+=("$2"); shift 2 ;;
            --video) VIDEO_URL="$2"; shift 2 ;;
            --audio) AUDIO_URL="$2"; shift 2 ;;
            --duration)
                DURATION="$2"
                if [[ ! "$DURATION" =~ ^[0-9]+$ ]] || ((DURATION < 4 || DURATION > 12)); then
                    error "Duration must be 4-12"
                fi
                shift 2 ;;
            --quality) QUALITY="$2"; shift 2 ;;
            --aspect-ratio) ASPECT_RATIO="$2"; shift 2 ;;
            --watermark) WATERMARK="$2"; shift 2 ;;
            --model) MODEL="$2"; shift 2 ;;
            --no-audio) GENERATE_AUDIO="false"; shift ;;
            --enable-audio) GENERATE_AUDIO="true"; shift ;;
            --audio) AUDIO_URL="$2"; shift 2 ;;
            -h|--help) error "Usage: $0 \"prompt\" [options]" ;;
            *) [[ -z "$PROMPT" ]] && PROMPT="$1" || error "Unknown: $1"; shift ;;
        esac
    done

    [[ -z "$PROMPT" ]] && error "Prompt required."

    [[ -n "$VIDEO_URL" && "$MODE" == "txt2video" ]] && MODE="vid2video"

    case $MODE in
        txt2img) OUTPUT_MODE="image" ;;
        txt2video|img2video|vid2video) OUTPUT_MODE="video" ;;
        *) error "Invalid mode: $MODE" ;;
    esac

    validate_args
}

validate_args() {
    if [[ "$OUTPUT_MODE" == "image" ]]; then
        QUALITY="${QUALITY:-2K}"
        ASPECT_RATIO="${ASPECT_RATIO:-1:1}"
        [[ ! "$QUALITY" =~ ^(2K|1K|HD)$ ]] && error "Image quality: 2K/1K/HD"
        [[ ! "$ASPECT_RATIO" =~ ^(1:1|16:9|9:16)$ ]] && error "Image ratio: 1:1/16:9/9:16"
    else
        QUALITY="${QUALITY:-720p}"
        ASPECT_RATIO="${ASPECT_RATIO:-16:9}"
        [[ ! "$QUALITY" =~ ^(480p|720p|1080p)$ ]] && error "Video quality: 480p/720p/1080p"
        [[ ! "$ASPECT_RATIO" =~ ^(16:9|9:16|1:1)$ ]] && error "Video ratio: 16:9/9:16/1:1"
    fi
}

# ── Node.js JSON 工具 (替代 jq) ───────────────────────────────────────────────

# 用临时文件传递 JSON，避免命令行参数转义问题
_TMPDIR="${TMPDIR:-${TEMP:-/tmp}}"
_JSON_FILE="${_TMPDIR}/next_video_gen_$$.json"

_cleanup_json() { rm -f "$_JSON_FILE"; }
trap _cleanup_json EXIT

# 提取 JSON 字段 (支持 data[0].url 语法)
# 用法: json_get "path" < "$resp_file"
json_get() {
    local path="$1"
    node -e "
var fs=require('fs');
var input=fs.readFileSync(process.argv[1],'utf8');
var path=process.argv[2];
try {
  var obj=JSON.parse(input);
  var parts=path.split('.').filter(Boolean);
  for(var i=0;i<parts.length;i++) {
    if(obj==null){console.log('');process.exit(0);}
    var p=parts[i];
    var m=p.match(/^([^\[]+)\[(\d+)\]$/);
    if(m){obj=obj[m[1]][parseInt(m[2])];}
    else{obj=obj[p];}
  }
  console.log(obj==null?'':(typeof obj==='string'?obj:JSON.stringify(obj)));
} catch(e){ console.log(''); }
" "$_JSON_FILE" "$path" || true
}

# 构建文生图 payload
build_image_payload() {
    local prompt_file="${_TMPDIR}/prompt_$$.txt"
    echo -n "$PROMPT" > "$prompt_file"

    IMAGE_MODEL="$IMAGE_MODEL" QUALITY="$QUALITY" ASPECT_RATIO="$ASPECT_RATIO" WATERMARK="$WATERMARK" PROMPT_FILE="$prompt_file" node -e "
var fs=require('fs');
var prompt=fs.readFileSync(process.env.PROMPT_FILE,'utf8');
console.log(JSON.stringify({
  model: process.env.IMAGE_MODEL,
  prompt: prompt,
  sequential_image_generation: 'disabled',
  response_format: 'url',
  size: process.env.QUALITY,
  aspect_ratio: process.env.ASPECT_RATIO,
  watermark: process.env.WATERMARK==='true',
  stream: false
}));
" || true

    rm -f "$prompt_file"
}

# 构建视频 payload
build_video_payload() {
    # ref images 转 JSON
    REF_JSON="[]"
    for img in "${REF_IMAGES[@]}"; do
        REF_JSON=$(REF_IMG="$img" node -e "
var arr=JSON.parse(process.env.REF||'[]');
arr.push({type:'image_url',image_url:{url:process.env.REF_IMG},role:'reference_image'});
console.log(JSON.stringify(arr));
" || true)
    done

    VIDEO_REF_JSON="null"
    [[ -n "$VIDEO_URL" ]] && VIDEO_REF_JSON=$(node -e "console.log(JSON.stringify({type:'video_url',video_url:{url:'$VIDEO_URL'},role:'reference_video'}))" || true)

    AUDIO_REF_JSON="null"
    [[ -n "$AUDIO_URL" ]] && AUDIO_REF_JSON=$(node -e "console.log(JSON.stringify({type:'audio_url',audio_url:{url:'$AUDIO_URL'},role:'reference_audio'}))" || true)

    node -e "
var refImages=JSON.parse(process.env.REF_JSON||'[]');
var content=[{type:'text',text:process.env.PROMPT}];
refImages.forEach(function(i){content.push(i);});
var v=$VIDEO_REF_JSON; if(v)content.push(v);
var a=$AUDIO_REF_JSON; if(a)content.push(a);
console.log(JSON.stringify({
  model:process.env.MODEL || process.env.VIDEO_MODEL,
  content:content,
  generate_audio:process.env.GENERATE_AUDIO==='true',
  ratio:process.env.ASPECT_RATIO,
  duration:parseInt(process.env.DURATION),
  watermark:process.env.WATERMARK==='true'
}));
" || true
}

# ── HTTP (Node.js 实现，跨平台) ─────────────────────────────────────────────
do_request() {
    local method="$1" url="$2" payload="$3"
    local resp; resp=$(node "${SCRIPT_DIR}/http-request.js" "$method" "$url" ${payload:+-d "$payload"} 2>&1) || true
    echo "$resp"
}

# ── 文生图 (同步) ──────────────────────────────────────────────────────────────
submit_image() {
    export IMAGE_MODEL QUALITY ASPECT_RATIO WATERMARK
    local payload; payload=$(build_image_payload)

    local resp http_code
    resp=$(do_request "POST" "${IMAGE_API_BASE}/generations" "$payload")
    http_code=$(echo "$resp" | tail -n1)
    resp=$(echo "$resp" | sed '$d')

    [[ "$http_code" != "200" ]] && handle_error "$http_code" "$resp"

    echo "$resp" > "$_JSON_FILE"
    local image_url; image_url=$(json_get "data[0].url")
    if [[ -z "$image_url" || "$image_url" == "null" ]]; then
        error "Failed to get image URL: $resp"
    fi

    echo "TASK_SUBMITTED: task_id=sync mode=文生图"
    echo "IMAGE_URL=$image_url"
    echo "RESOLUTION=${QUALITY}"
    echo "ASPECT_RATIO=${ASPECT_RATIO}"
    echo "ELAPSED=0s"
    download_file "$image_url" "img"
}

# ── 视频 (异步) ───────────────────────────────────────────────────────────────
submit_video() {
    # ref images 转 JSON
    REF_JSON="[]"
    for img in "${REF_IMAGES[@]}"; do
        REF_JSON=$(REF_IMG="$img" node -e "
var arr=JSON.parse(process.env.REF||'[]');
arr.push({type:'image_url',image_url:{url:process.env.REF_IMG},role:'reference_image'});
console.log(JSON.stringify(arr));
" || true)
    done

    export PROMPT VIDEO_MODEL QUALITY ASPECT_RATIO DURATION GENERATE_AUDIO WATERMARK MODEL
    export REF_JSON VIDEO_URL AUDIO_URL

    local payload; payload=$(build_video_payload)

    local resp http_code
    resp=$(do_request "POST" "${VIDEO_API_BASE}" "$payload")
    http_code=$(echo "$resp" | tail -n1)
    resp=$(echo "$resp" | sed '$d')

    [[ "$http_code" != "200" ]] && handle_error "$http_code" "$resp"

    echo "$resp" > "$_JSON_FILE"
    local task_id; task_id=$(json_get "id")
    if [[ -z "$task_id" || "$task_id" == "null" ]]; then
        error "Failed to get task_id: $resp"
    fi

    GLOBAL_TASK_ID="$task_id"
    local mode_info
    case $MODE in
        txt2video) [[ "$GENERATE_AUDIO" == "true" ]] && mode_info="文生音画" || mode_info="文生视频" ;;
        img2video) [[ "$GENERATE_AUDIO" == "true" ]] && mode_info="图生音画" || mode_info="图生视频" ;;
        vid2video) mode_info="素材生视频" ;;
    esac
    echo "TASK_SUBMITTED: task_id=${task_id} mode=${mode_info}"
}

poll_video() {
    local task_id=$1
    local start_time=$(date +%s)
    local last_report=-1

    while true; do
        local elapsed=$(( $(date +%s) - start_time ))

        if (( elapsed > MAX_POLL_SECONDS )); then
            echo "POLL_TIMEOUT: task_id=${task_id}"
            warn "Timed out. Check: https://console.volcengine.com/ark"
            exit 1
        fi

        local bucket=$(( elapsed / PROGRESS_INTERVAL ))
        if (( bucket > last_report && elapsed >= PROGRESS_INTERVAL )); then
            last_report=$bucket
            echo "STATUS_UPDATE: 视频生成中... (已等待 ${elapsed} 秒)"
        fi

        sleep "$POLL_INTERVAL"

        local resp http_code
        resp=$(do_request "GET" "${VIDEO_API_BASE}/${task_id}" "")
        http_code=$(echo "$resp" | tail -n1)
        resp=$(echo "$resp" | sed '$d')

        [[ "$http_code" != "200" ]] && handle_error "$http_code" "$resp"

        echo "$resp" > "$_JSON_FILE"
        local status; status=$(json_get "status")

        case $status in
            succeeded)
                handle_video_success "$resp" "$elapsed"
                return 0
                ;;
            failed)
                local err; err=$(json_get "error")
                [[ -z "$err" ]] && err=$(json_get "message")
                [[ -z "$err" ]] && err="Unknown"
                error "Generation failed: $err"
                ;;
            running|pending) ;;
            "") echo "STATUS_UPDATE: 等待中... (${elapsed}秒)" ;;
            *) echo "STATUS_UPDATE: ${status}... (${elapsed}秒)" ;;
        esac
    done
}

handle_video_success() {
    local resp=$1 elapsed=$2

    echo "$resp" > "$_JSON_FILE"

    echo "ELAPSED=${elapsed}s"
    local vu du ra re ha
    vu=$(json_get "content.video_url")
    du=$(json_get "duration")
    ra=$(json_get "ratio")
    re=$(json_get "resolution")
    ha=$(json_get "generate_audio")

    if [[ -z "$vu" || "$vu" == "null" ]]; then
        error "No video URL: $resp"
    fi

    [[ -n "$du" && "$du" != "null" ]] && echo "DURATION=${du}s"
    [[ -n "$ra" && "$ra" != "null" ]] && echo "ASPECT_RATIO=${ra}"
    [[ -n "$re" && "$re" != "null" ]] && echo "RESOLUTION=${re}"
    echo "HAS_AUDIO=${ha}"
    echo "VIDEO_URL=$vu"
    download_file "$vu" "video"
}

# ── 通用 ──────────────────────────────────────────────────────────────────────
handle_error() {
    case $1 in
        401) error "Invalid API key. https://console.volcengine.com/ark" ;;
        403) error "Access forbidden. Check API key permissions." ;;
        429) error "Rate limit exceeded. Wait and retry." ;;
        500|502|503) error "Service error. Try again later." ;;
        *) error "API error ($1): $2" ;;
    esac
}

OUTPUT_DIR="${NEXT_VIDEO_GEN_OUTPUT_DIR:-$HOME/Videos/next-video-gen}"
mkdir -p "$OUTPUT_DIR"

download_file() {
    local url=$1 type=$2 ts=$(date +%Y%m%d_%H%M%S)
    local ext; [[ "$type" == "img" ]] && ext="png" || ext="mp4"
    local fp="${OUTPUT_DIR}/${type}_${ts}.${ext}"

    info "正在下载${type}..."
    if node "${SCRIPT_DIR}/download-file.js" "$url" "$fp" 2>/dev/null; then
        success "${type}已保存: $fp"
        echo "LOCAL_FILE=$fp"
        [[ "$(uname)" == "Darwin" ]] && open "$OUTPUT_DIR"
    else
        warn "${type}下载失败，请使用在线链接"
    fi
}

# ── 主入口 ────────────────────────────────────────────────────────────────────
main() {
    check_dependencies
    check_api_key
    parse_args "$@"

    if [[ "$OUTPUT_MODE" == "image" ]]; then
        submit_image
    else
        submit_video
        poll_video "$GLOBAL_TASK_ID"
    fi
}

main "$@"


path: _meta.json
content: slug: next-video-gen
version: 1.0.0

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