Skill: zhouweico/wan-video-gen (version 0.1.0)

--- name: wan-video-gen description: | 基于阿里云百炼 Wan 文生视频模型的视频生成 skill。支持提交视频生成任务、轮询任务状态、下载生成视频到本地。 当用户需要根据提示词生成视频、继续查询已有视频生成任务，或用 Wan 系列模型生成带声/无声视频时，使用此 skill。 homepage: https://help.aliyun.com/zh/model-studio/text-to-video-api-reference metadata: openclaw: requires: env: - DASHSCOPE_API_KEY bins: - node primaryEnv: DASHSCOPE_API_KEY --- # Wan Video Gen 通过阿里云百炼 Wan 文生视频接口创建异步任务，并自动轮询、下载生成结果。 详细接口与限制见 `references/api.md`。 ## Setup 1. 配置 API Key： ```bash export DASHSCOPE_API_KEY="sk-xxx" ``` 也可以在 skill 目录下创建 `config.json`： 可先参考 `config.example.json` 再复制为 `config.json`。 ```json { "apiKey": "sk-xxx", "baseUrl": "https://dashscope.aliyuncs.com", "defaultGoal": "balanced", "goals": { "cheap": { "tier": "draft" }, "balanced": { "tier": "standard" }, "quality": { "tier": "final" } }, "defaultTier": "standard", "tiers": { "draft": { "model": "wan2.2-t2v-plus", "quality": "480p", "ratio": "16:9", "duration": 5 }, "standard": { "model": "wan2.5-t2v-preview", "quality": "720p", "ratio": "16:9", "duration": 5 }, "final": { "model": "wan2.6-t2v", "quality": "1080p", "ratio": "16:9", "duration": 5 } }, "outputDir": "./outputs" } ``` 2. 运行环境要求 `Node.js >= 18` 配置优先级： - CLI 参数 - 环境变量 - `config.json` - 脚本默认值 ## 默认模型 - 默认档位为 `final`，对应 `wan2.6-t2v` - 推荐优先使用 `--tier`，只有明确要指定模型时再传 `--model` ## 目标驱动选型 - `cheap` -> `draft` - `balanced` -> `standard` - `quality` -> `final` 推荐把这组映射维护在 `config.json` 的 `goals` 里；`goal` 表示用户目标，`tier` 表示具体模型档位和默认分辨率方案。 ## 档位驱动选型 - `draft` -> `wan2.2-t2v-plus` + 默认 `480p` + `5秒` - `standard` -> `wan2.5-t2v-preview` + 默认 `720p` + `5秒` - `final` -> `wan2.6-t2v` + 默认 `1080p` + `5秒` 推荐把这组映射维护在 `config.json` 的 `tiers` 里，而不是改主脚本。 ## CLI 主要脚本：`node scripts/wan-video-gen.js` ### 基础文生视频 ```bash node scripts/wan-video-gen.js \ --prompt="一只小猫在月光下奔跑" \ --goal="balanced" ``` ### 提交任务但不等待 ```bash node scripts/wan-video-gen.js \ --prompt="复古 70 年代风格地铁站中的街头音乐家" \ --no-wait ``` ### 继续轮询已有任务 ```bash node scripts/wan-video-gen.js \ --task-id="你的task_id" ``` ### 仅做预检，不实际提交 ```bash node scripts/wan-video-gen.js \ --prompt="复古 70 年代风格地铁站中的街头音乐家" \ --goal="cheap" \ --dry-run ``` ## 参数约定 - `--prompt`：正向提示词。提交新任务时必填 - `--negative-prompt`：反向提示词 - `--audio-url`：自定义音频 URL，仅 `wan2.6` / `wan2.5` 系列支持 - `--goal=cheap|balanced|quality`：按用户目标选择更合适的默认模型和分辨率方案 - `--tier=draft|standard|final`：按成本/质量档位自动选默认模型和分辨率方案 - `--model`：模型名，默认 `wan2.6-t2v` - 优先级：`model > tier > goal > 默认` - `config.json` 中可通过 `defaultGoal/defaultTier` 与 `goals/tiers` 维护映射 - `--quality`：分辨率档位，`480p|720p|1080p` - `--ratio`：视频比例，`16:9|9:16|1:1|4:3|3:4` - `--size`：输出尺寸，如 `1280*720` - 传给接口时必须是具体数值，不能写成 `16:9` 或 `720P` - 优先级：`size > quality+ratio > model 默认值` - `--duration`：视频秒数，默认 `5` - `wan2.6-t2v`：支持 `2` 到 `15` - `wan2.6-t2v-us`：仅支持 `5`、`10` - `wan2.5-t2v-preview`：仅支持 `5`、`10` - `wan2.2-t2v-plus` / `wanx2.1-t2v-plus` / `wanx2.1-t2v-turbo`：固定 `5` - `--prompt-extend=true|false`：是否启用提示词智能改写，默认 `true` - `--shot-type=single|multi`：镜头模式，`wan2.6` 支持 `multi` - `--seed`：随机种子 - `--task-id`：查询已有任务，不再提交新任务 - `--no-wait`：只提交任务，返回 task_id - `--dry-run`：只打印最终请求体和配置预检，不实际调用 API - `--poll-interval`：轮询间隔秒数，默认 `15` - `--timeout`：总等待超时秒数，默认 `1800` - `--output-dir`：视频下载目录，默认 `outputs/` ## 模型默认分辨率 - `wan2.6-t2v` / `wan2.6-t2v-us`：默认 `1920*1080` - `wan2.5-t2v-preview`：默认 `1920*1080` - `wan2.2-t2v-plus`：默认 `1920*1080` - `wanx2.1-t2v-turbo`：默认 `1280*720` - `wanx2.1-t2v-plus`：默认 `1280*720` ## 模型时长限制 - `wan2.6-t2v`：`2` 到 `15` 秒，默认 `5` - `wan2.6-t2v-us`：`5` 或 `10` 秒，默认 `5` - `wan2.5-t2v-preview`：`5` 或 `10` 秒，默认 `5` - `wan2.2-t2v-plus`：固定 `5` 秒 - `wanx2.1-t2v-plus`：固定 `5` 秒 - `wanx2.1-t2v-turbo`：固定 `5` 秒 ## 官方推荐尺寸 - `480p` - `16:9=832*480` - `9:16=480*832` - `1:1=624*624` - `720p` - `16:9=1280*720` - `9:16=720*1280` - `1:1=960*960` - `4:3=1088*832` - `3:4=832*1088` - `1080p` - `16:9=1920*1080` - `9:16=1080*1920` - `1:1=1440*1440` - `4:3=1632*1248` - `3:4=1248*1632` ## 工作流 1. 用户要生视频时，提交异步任务。 2. 提交前脚本会显示一次成本提醒，按中国内地价格表估算总费用。 3. 返回 `task_id` 后轮询 `GET /api/v1/tasks/{task_id}`。 4. 成功后立即下载 MP4 到本地目录。 5. 如果只想拿 `task_id`，使用 `--no-wait`。 6. 先按较低分辨率验证 prompt，再切到更高分辨率，避免无效消耗。 ## 覆盖优先级 - 模型选择：`CLI --model > CLI --tier > CLI --goal > config.defaultGoal/config.goal > config.defaultTier/config.tier > config.model > 内置默认` - 分辨率选择：`CLI --size > CLI (--quality + --ratio) > goal/tier 默认 quality/ratio > 模型默认` - 时长选择：`CLI --duration > goal/tier 默认 duration > 模型默认` - `goal` 用于表达用户目标，`tier` 用于表达具体模型档位；显式 `--tier` 会覆盖 `goal` - 也就是说，`--goal balanced` 或 `--tier standard` 之后，你仍然可以显式传 `--quality`、`--ratio`、`--duration` 覆盖默认值 ## 成本提醒 - 脚本会在 `preflight` 和 `--dry-run` 中输出预计费用 - 当前内置的是你提供的中国内地单价表 - 视频费用按 `单价（元/秒） × 时长（秒）` 估算 - 单价取决于模型和分辨率档位 - 这不是实时计费查询；如果官方价格调整，需要同步更新脚本 ## 失败时优先检查 - `DASHSCOPE_API_KEY` 或 `config.json` 是否配置正确 - `baseUrl` 和 API Key 是否属于同一地域 - 尺寸和时长是否符合所选模型限制 - 是否把 `16:9`、`720p` 这类标签误当成 `--size` 传入 - 所选模型是否支持当前 `quality` 档位 - 所选模型是否支持当前 `duration` - `wan2.2` 系列是否错误传了可变时长或音频 URL - `task_id` 是否已超过 24 小时

path: config.example.json
content: {
  "apiKey": "sk-xxxx",
  "baseUrl": "https://dashscope.aliyuncs.com",
  "defaultGoal": "balanced",
  "goals": {
    "cheap": {
      "tier": "draft"
    },
    "balanced": {
      "tier": "standard"
    },
    "quality": {
      "tier": "final"
    }
  },
  "defaultTier": "standard",
  "tiers": {
    "draft": {
      "model": "wan2.2-t2v-plus",
      "quality": "480p",
      "ratio": "16:9",
      "duration": 5
    },
    "standard": {
      "model": "wan2.5-t2v-preview",
      "quality": "720p",
      "ratio": "16:9",
      "duration": 5
    },
    "final": {
      "model": "wan2.6-t2v",
      "quality": "1080p",
      "ratio": "16:9",
      "duration": 5
    }
  },
  "outputDir": "./outputs"
}


path: package.json
content: {
  "name": "wan-video-gen",
  "version": "0.1.0",
  "private": true,
  "description": "Wan video generation skill for qclaw",
  "engines": {
    "node": ">=18"
  },
  "scripts": {
    "gen": "node scripts/wan-video-gen.js"
  }
}


path: scripts/wan-video-gen.js
content: #!/usr/bin/env node

const fs = require('fs');
const path = require('path');

const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
const DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com';
const DEFAULT_MODEL = 'wan2.6-t2v';
const DEFAULT_OUTPUT_DIR = path.join(__dirname, '..', 'outputs');
const DEFAULT_POLL_INTERVAL = 15;
const DEFAULT_TIMEOUT = 1800;
const VIDEO_SIZE_PRESETS = {
  '480p': {
    '16:9': '832*480',
    '9:16': '480*832',
    '1:1': '624*624'
  },
  '720p': {
    '16:9': '1280*720',
    '9:16': '720*1280',
    '1:1': '960*960',
    '4:3': '1088*832',
    '3:4': '832*1088'
  },
  '1080p': {
    '16:9': '1920*1080',
    '9:16': '1080*1920',
    '1:1': '1440*1440',
    '4:3': '1632*1248',
    '3:4': '1248*1632'
  }
};
const MODEL_SIZE_RULES = {
  'wan2.6-t2v': { defaultQuality: '1080p', qualities: ['720p', '1080p'] },
  'wan2.6-t2v-us': { defaultQuality: '1080p', qualities: ['720p', '1080p'] },
  'wan2.5-t2v-preview': { defaultQuality: '1080p', qualities: ['480p', '720p', '1080p'] },
  'wan2.2-t2v-plus': { defaultQuality: '1080p', qualities: ['480p', '1080p'] },
  'wanx2.1-t2v-turbo': { defaultQuality: '720p', qualities: ['480p', '720p'] },
  'wanx2.1-t2v-plus': { defaultQuality: '720p', qualities: ['720p'] }
};
const MODEL_DURATION_RULES = {
  'wan2.6-t2v': { type: 'range', min: 2, max: 15, defaultValue: 5 },
  'wan2.6-t2v-us': { type: 'enum', values: [5, 10], defaultValue: 5 },
  'wan2.5-t2v-preview': { type: 'enum', values: [5, 10], defaultValue: 5 },
  'wan2.2-t2v-plus': { type: 'fixed', value: 5, defaultValue: 5 },
  'wanx2.1-t2v-plus': { type: 'fixed', value: 5, defaultValue: 5 },
  'wanx2.1-t2v-turbo': { type: 'fixed', value: 5, defaultValue: 5 }
};
const VIDEO_PRICING_CNY_PER_SEC = {
  'wan2.6-t2v': { '720p': 0.6, '1080p': 1.0 },
  'wan2.5-t2v-preview': { '480p': 0.3, '720p': 0.6, '1080p': 1.0 },
  'wan2.2-t2v-plus': { '480p': 0.14, '1080p': 0.7 },
  'wanx2.1-t2v-turbo': { '480p': 0.24, '720p': 0.24 },
  'wanx2.1-t2v-plus': { '720p': 0.7 }
};
const VIDEO_TIER_PROFILES = {
  draft: { model: 'wan2.2-t2v-plus', quality: '480p', ratio: '16:9', duration: 5 },
  standard: { model: 'wan2.5-t2v-preview', quality: '720p', ratio: '16:9', duration: 5 },
  final: { model: 'wan2.6-t2v', quality: '1080p', ratio: '16:9', duration: 5 }
};
const VIDEO_GOAL_PROFILES = {
  cheap: { tier: 'draft' },
  balanced: { tier: 'standard' },
  quality: { tier: 'final' }
};
const DEFAULT_TIER = 'final';

function parseArgs(argv) {
  const args = {};
  for (let i = 0; i < argv.length; i += 1) {
    const [REDACTED_SECRET];
    if (!token.startsWith('--')) {
      continue;
    }
    const body = token.slice(2);
    const eqIndex = body.indexOf('=');
    let key;
    let value;
    if (eqIndex >= 0) {
      key = body.slice(0, eqIndex);
      value = body.slice(eqIndex + 1);
    } else {
      key = body;
      const next = argv[i + 1];
      if (next && !next.startsWith('--')) {
        value = next;
        i += 1;
      } else {
        value = 'true';
      }
    }
    if (args[key] === undefined) {
      args[key] = value;
    } else if (Array.isArray(args[key])) {
      args[key].push(value);
    } else {
      args[key] = [args[key], value];
    }
  }
  return args;
}

function printUsage() {
  console.log(`
Usage:
  node scripts/wan-video-gen.js --prompt="..." [options]
  node scripts/wan-video-gen.js --task-id TASK_ID [options]

Options:
  --prompt              Prompt for a new video task.
  --negative-prompt     Negative prompt.
  --audio-url           Optional audio URL for wan2.6/wan2.5.
  --goal                cheap|balanced|quality
  --tier                draft|standard|final
  --model               Default: wan2.6-t2v
  --quality             480p|720p|1080p
  --ratio               16:9|9:16|1:1|4:3|3:4
  --size                Example: 1280*720
  --duration            Duration seconds. Default: 5
  --prompt-extend       true|false. Default: true
  --shot-type           single|multi
  --seed                Optional random seed.
  --task-id             Poll an existing task instead of creating one.
  --no-wait             Submit only, do not poll.
  --dry-run             Print resolved request and exit.
  --poll-interval       Seconds between polls. Default: 15
  --timeout             Max wait seconds. Default: 1800
  --output-dir          Download directory. Default: outputs/

Examples:
  node scripts/wan-video-gen.js --prompt="一只小猫在月光下奔跑" --tier=standard
  node scripts/wan-video-gen.js --prompt="复古地铁站街头音乐家" --no-wait
  node scripts/wan-video-gen.js --task-id="966cebcd-xxxx"
`);
}

function ensureDir(dirPath) {
  fs.mkdirSync(dirPath, { recursive: true });
}

function toBoolean(value, defaultValue = false) {
  if (value === undefined) {
    return defaultValue;
  }
  return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}

function toNumber(value, defaultValue) {
  if (value === undefined) {
    return defaultValue;
  }
  const num = Number(value);
  return Number.isFinite(num) ? num : defaultValue;
}

function firstNonEmpty(...values) {
  for (const value of values) {
    if (value === undefined || value === null) {
      continue;
    }
    if (typeof value === 'string' && value.trim() === '') {
      continue;
    }
    return value;
  }
  return undefined;
}

function loadConfig() {
  if (!fs.existsSync(CONFIG_PATH)) {
    return {};
  }
  const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
  const parsed = JSON.parse(raw);
  return parsed && typeof parsed === 'object' ? parsed : {};
}

function sanitizeFilename(input) {
  return String(input).replace(/[^a-zA-Z0-9._-]+/g, '-');
}

function formatCny(value) {
  return `${value.toFixed(2)} 元`;
}

function qualityForSize(size) {
  for (const [quality, presets] of Object.entries(VIDEO_SIZE_PRESETS)) {
    if (Object.values(presets).includes(size)) {
      return quality;
    }
  }
  return null;
}

function estimateVideoCost(model, size, duration) {
  const quality = qualityForSize(size);
  if (!quality) {
    return null;
  }

  const pricingMap = VIDEO_PRICING_CNY_PER_SEC[model];
  if (!pricingMap || pricingMap[quality] === undefined) {
    return null;
  }

  const unitPrice = pricingMap[quality];
  return {
    quality,
    unitPrice,
    totalPrice: unitPrice * duration
  };
}

function resolveConfiguredVideoTiers(config) {
  if (!config.tiers || typeof config.tiers !== 'object') {
    return VIDEO_TIER_PROFILES;
  }

  const tiers = {};
  for (const [name, profile] of Object.entries(config.tiers)) {
    if (!profile || typeof profile !== 'object' || typeof profile.model !== 'string' || !profile.model.trim()) {
      throw new Error(`config.tiers.${name} 必须包含非空 model`);
    }
    tiers[name] = {
      model: profile.model,
      quality: profile.quality,
      ratio: profile.ratio,
      duration: profile.duration
    };
  }
  return Object.keys(tiers).length ? tiers : VIDEO_TIER_PROFILES;
}

function resolveConfiguredVideoGoals(config, tiers) {
  if (!config.goals || typeof config.goals !== 'object') {
    return VIDEO_GOAL_PROFILES;
  }

  const goals = {};
  for (const [name, profile] of Object.entries(config.goals)) {
    if (!profile || typeof profile !== 'object') {
      throw new Error(`config.goals.${name} 必须是对象`);
    }
    if (!profile.model && !profile.tier) {
      throw new Error(`config.goals.${name} 必须至少包含 model 或 tier`);
    }
    if (profile.tier && !tiers[profile.tier]) {
      throw new Error(`config.goals.${name}.tier 不支持: ${profile.tier}`);
    }
    const tierProfile = profile.tier ? tiers[profile.tier] : null;
    goals[name] = {
      tier: profile.tier,
      model: profile.model || (tierProfile ? tierProfile.model : undefined),
      quality: profile.quality || (tierProfile ? tierProfile.quality : undefined),
      ratio: profile.ratio || (tierProfile ? tierProfile.ratio : undefined),
      duration: profile.duration !== undefined ? profile.duration : tierProfile ? tierProfile.duration : undefined
    };
  }
  return Object.keys(goals).length ? goals : VIDEO_GOAL_PROFILES;
}

function resolveVideoSelection(args, config) {
  const tiers = resolveConfiguredVideoTiers(config);
  const goals = resolveConfiguredVideoGoals(config, tiers);
  const configuredDefaultGoal = firstNonEmpty(config.defaultGoal, config.goal);
  const configuredDefaultTier = firstNonEmpty(config.defaultTier, config.tier);

  if (args.model) {
    return { model: args.model, tier: null, goal: null, profile: null, source: 'cli-model', tiers, goals };
  }

  if (args.tier) {
    const profile = tiers[args.tier];
    if (!profile) {
      throw new Error(`不支持的 --tier: ${args.tier}，可选值为 ${Object.keys(tiers).join('|')}`);
    }
    return { model: profile.model, tier: args.tier, goal: null, profile, source: 'cli-tier', tiers, goals };
  }

  if (args.goal) {
    const goalProfile = goals[args.goal];
    if (!goalProfile) {
      throw new Error(`不支持的 --goal: ${args.goal}，可选值为 ${Object.keys(goals).join('|')}`);
    }
    return { model: goalProfile.model, tier: goalProfile.tier || null, goal: args.goal, profile: goalProfile, source: 'cli-goal', tiers, goals };
  }

  if (process.env.WAN_VIDEO_MODEL) {
    return { model: process.env.WAN_VIDEO_MODEL, tier: null, goal: null, profile: null, source: 'env-model', tiers, goals };
  }

  if (configuredDefaultGoal) {
    const goalProfile = goals[configuredDefaultGoal];
    if (!goalProfile) {
      throw new Error(`config.defaultGoal 不支持: ${configuredDefaultGoal}，可选值为 ${Object.keys(goals).join('|')}`);
    }
    return {
      model: goalProfile.model,
      tier: goalProfile.tier || null,
      goal: configuredDefaultGoal,
      profile: goalProfile,
      source: 'config-goal',
      tiers,
      goals
    };
  }

  if (configuredDefaultTier) {
    const profile = tiers[configuredDefaultTier];
    if (!profile) {
      throw new Error(`config.defaultTier 不支持: ${configuredDefaultTier}，可选值为 ${Object.keys(tiers).join('|')}`);
    }
    return { model: profile.model, tier: configuredDefaultTier, goal: null, profile, source: 'config-tier', tiers, goals };
  }

  if (config.model) {
    return { model: config.model, tier: null, goal: null, profile: null, source: 'config-model', tiers, goals };
  }

  return {
    model: DEFAULT_MODEL,
    tier: DEFAULT_TIER,
    goal: null,
    profile: VIDEO_TIER_PROFILES.final,
    source: 'default',
    tiers,
    goals
  };
}

function printPreflight({ model, tier, goal, selectionSource, selectionNote, size, duration, outputDir, taskId }) {
  const mode = taskId ? 'poll-existing-task' : 'create-task';
  console.error(`preflight mode=${mode}`);
  console.error(`preflight model=${model}`);
  console.error(`preflight selection_source=${selectionSource}${goal ? ` goal=${goal}` : ''}${tier ? ` tier=${tier}` : ''}`);
  if (selectionNote) {
    console.error(`preflight note=${selectionNote}`);
  }
  if (!taskId) {
    console.error(`preflight size=${size} duration=${duration}`);
  } else {
    console.error(`preflight task_id=${taskId}`);
  }
  console.error(`preflight output_dir=${outputDir}`);

  if (!taskId) {
    const estimated = estimateVideoCost(model, size, duration);
    if (estimated) {
      console.error(
        `cost_estimate region=中国内地 quality=${estimated.quality} unit=${formatCny(estimated.unitPrice)}/秒 total=${formatCny(estimated.totalPrice)}`
      );
    } else {
      console.error('cost_estimate region=中国内地 unavailable=未内置该模型或地域价格');
    }
  }
}

function getModelRule(model) {
  return MODEL_SIZE_RULES[model] || MODEL_SIZE_RULES[DEFAULT_MODEL];
}

function getDurationRule(model) {
  return MODEL_DURATION_RULES[model] || MODEL_DURATION_RULES[DEFAULT_MODEL];
}

function resolveVideoSize(model, args, config, profile) {
  if (args.size) {
    return args.size;
  }

  const rule = getModelRule(model);
  const quality = String(firstNonEmpty(args.quality, config.quality, profile && profile.quality, rule.defaultQuality)).toLowerCase();
  const ratio = String(firstNonEmpty(args.ratio, config.ratio, profile && profile.ratio, '16:9'));

  if (!rule.qualities.includes(quality)) {
    throw new Error(`${model} 不支持质量档位 ${quality}，可选值为 ${rule.qualities.join('|')}`);
  }

  const presetGroup = VIDEO_SIZE_PRESETS[quality];
  const size = presetGroup && presetGroup[ratio];
  if (!size) {
    throw new Error(`${quality} 不支持比例 ${ratio}，可选值为 ${Object.keys(presetGroup || {}).join('|')}`);
  }

  return size;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function requestJson(url, options) {
  const response = await fetch(url, options);
  const text = await response.text();
  let parsed = {};
  try {
    parsed = text ? JSON.parse(text) : {};
  } catch (_) {
    parsed = { raw: text };
  }

  if (!response.ok) {
    throw new Error(`请求失败: ${response.status} ${response.statusText}\n${text}`);
  }

  return parsed;
}

function buildCreateBody(model, args, profile) {
  const size = resolveVideoSize(model, args, {}, profile);
  const durationRule = getDurationRule(model);
  const duration =
    args.duration !== undefined
      ? Math.trunc(toNumber(args.duration, durationRule.defaultValue))
      : profile && profile.duration !== undefined
        ? profile.duration
        : durationRule.defaultValue;
  const body = {
    model,
    input: {
      prompt: args.prompt
    },
    parameters: {
      size,
      prompt_extend: toBoolean(args['prompt-extend'], true)
    }
  };

  if (args['negative-prompt']) {
    body.input.negative_prompt = args['negative-prompt'];
  }
  if (args['audio-url']) {
    body.input.audio_url = args['audio-url'];
  }
  if (args.duration !== undefined || (profile && profile.duration !== undefined)) {
    body.parameters.duration = duration;
  }
  if (args['shot-type']) {
    body.parameters.shot_type = args['shot-type'];
  }
  if (args.seed !== undefined) {
    body.parameters.seed = Math.trunc(toNumber(args.seed, 0));
  }

  return body;
}

function validateDuration(model, args, profile) {
  const rule = getDurationRule(model);
  const requested = args.duration !== undefined
    ? Math.trunc(toNumber(args.duration, rule.defaultValue))
    : profile && profile.duration !== undefined
      ? profile.duration
      : rule.defaultValue;

  if (rule.type === 'fixed') {
    if (args.duration !== undefined && requested !== rule.value) {
      throw new Error(`${model} 时长固定为 ${rule.value} 秒，不支持修改 --duration`);
    }
    return rule.value;
  }

  if (rule.type === 'enum') {
    if (!rule.values.includes(requested)) {
      throw new Error(`${model} 仅支持时长 ${rule.values.join('、')} 秒`);
    }
    return requested;
  }

  if (rule.type === 'range') {
    if (requested < rule.min || requested > rule.max) {
      throw new Error(`${model} 支持时长范围为 ${rule.min} 到 ${rule.max} 秒`);
    }
    return requested;
  }

  return requested;
}

function validateArgs(model, args, size) {
  const isLegacySilent = model.startsWith('wan2.2') || model.startsWith('wanx2.1');
  if (isLegacySilent && args['audio-url']) {
    throw new Error(`${model} 不支持 --audio-url，音频输入仅适用于 wan2.6/wan2.5 系列`);
  }
  if (args['shot-type'] && !model.startsWith('wan2.6')) {
    throw new Error('--shot-type 目前仅在 wan2.6 系列中启用');
  }
  if (!/^\d+\*\d+$/.test(String(size))) {
    throw new Error(`非法尺寸格式: ${size}，应为 宽*高，例如 1280*720`);
  }

  const allowedSizes = new Set();
  getModelRule(model).qualities.forEach((quality) => {
    Object.values(VIDEO_SIZE_PRESETS[quality]).forEach((value) => allowedSizes.add(value));
  });
  if (!allowedSizes.has(size)) {
    throw new Error(`${model} 不支持尺寸 ${size}。请使用 --size 指定官方允许值，或改用 --quality + --ratio`);
  }
}

async function createTask(baseUrl, apiKey, body) {
  const result = await requestJson(`${baseUrl}/api/v1/services/aigc/video-generation/video-synthesis`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      'X-DashScope-Async': 'enable'
    },
    body: JSON.stringify(body)
  });

  const taskId = result && result.output && result.output.task_id;
  if (!taskId) {
    throw new Error(`创建任务失败，响应中缺少 task_id: ${JSON.stringify(result)}`);
  }

  return result;
}

async function fetchTask(baseUrl, apiKey, taskId) {
  return requestJson(`${baseUrl}/api/v1/tasks/${encodeURIComponent(taskId)}`, {
    method: 'GET',
    headers: {
      Authorization: `Bearer ${apiKey}`
    }
  });
}

async function waitForTask(baseUrl, apiKey, taskId, pollIntervalSec, timeoutSec) {
  const startedAt = Date.now();

  while (true) {
    const status = await fetchTask(baseUrl, apiKey, taskId);
    const output = status && status.output ? status.output : {};
    const taskStatus = output.task_status || 'UNKNOWN';
    console.error(`task=${taskId} status=${taskStatus}`);

    if (['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN'].includes(taskStatus)) {
      return status;
    }

    if (Date.now() - startedAt > timeoutSec * 1000) {
      throw new Error(`等待任务超时: ${taskId}`);
    }

    await sleep(pollIntervalSec * 1000);
  }
}

async function downloadFile(url, filePath) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`下载失败: ${response.status} ${response.statusText}`);
  }
  const arrayBuffer = await response.arrayBuffer();
  fs.writeFileSync(filePath, Buffer.from(arrayBuffer));
}

async function main() {
  if (typeof fetch !== 'function') {
    throw new Error('当前 Node.js 不支持全局 fetch，请升级到 Node.js 18 或更高版本');
  }

  const args = parseArgs(process.argv.slice(2));
  if (args.help || args.h) {
    printUsage();
    return;
  }

  const config = loadConfig();
  const dryRun = toBoolean(args['dry-run'], false);
  const [REDACTED_SECRET], config.apiKey);

  const baseUrl = String(
    firstNonEmpty(process.env.DASHSCOPE_BASE_URL, config.baseUrl, DEFAULT_BASE_URL)
  ).replace(/\/$/, '');
  const selection = resolveVideoSelection(args, config);
  const model = selection.model;
  const selectionNote =
    config.model && firstNonEmpty(config.defaultGoal, config.goal, config.defaultTier, config.tier)
      ? 'config 同时包含 model 与 goal/tier 默认项，当前优先使用 goal/tier'
      : '';
  const outputDirInput = firstNonEmpty(args['output-dir'], config.outputDir, DEFAULT_OUTPUT_DIR);
  const outputDir = path.isAbsolute(outputDirInput)
    ? outputDirInput
    : path.resolve(path.join(__dirname, '..'), outputDirInput);
  const size = resolveVideoSize(model, args, config, selection.profile);

  const pollIntervalSec = Math.max(1, Math.trunc(toNumber(args['poll-interval'], DEFAULT_POLL_INTERVAL)));
  const timeoutSec = Math.max(30, Math.trunc(toNumber(args.timeout, DEFAULT_TIMEOUT)));
  const taskId = args['task-id'];
  const duration = validateDuration(model, args, selection.profile);

  if (!taskId && !args.prompt) {
    printUsage();
    throw new Error('提交新任务需要 --prompt，或使用 --task-id 查询已有任务');
  }

  if (!apiKey && !dryRun) {
    throw new Error(`缺少 DASHSCOPE_API_KEY。请设置环境变量，或在 ${CONFIG_PATH} 中配置 apiKey`);
  }

  printPreflight({
    model,
    tier: selection.tier,
    goal: selection.goal,
    selectionSource: selection.source,
    selectionNote,
    size,
    duration,
    outputDir,
    taskId
  });

  let createdTaskId = taskId;
  if (!createdTaskId) {
    validateArgs(model, args, size);
    const body = buildCreateBody(model, { ...args, size, duration }, selection.profile);
    if (dryRun) {
      console.log(JSON.stringify({
        baseUrl,
        endpoint: '/api/v1/services/aigc/video-generation/video-synthesis',
        body
      }, null, 2));
      return;
    }
    const created = await createTask(baseUrl, apiKey, body);
    createdTaskId = created.output.task_id;
    console.log(`task_id=${createdTaskId}`);
    console.error(`submitted model=${model}`);

    if (toBoolean(args['no-wait'], false)) {
      return;
    }
  }

  const status = await waitForTask(baseUrl, apiKey, createdTaskId, pollIntervalSec, timeoutSec);
  const output = status && status.output ? status.output : {};

  if (output.task_status !== 'SUCCEEDED') {
    throw new Error(`任务未成功结束: ${JSON.stringify(output)}`);
  }

  if (!output.video_url) {
    throw new Error(`任务成功但未找到 video_url: ${JSON.stringify(status)}`);
  }

  ensureDir(outputDir);
  const savePath = path.join(outputDir, `${sanitizeFilename(createdTaskId)}.mp4`);
  await downloadFile(output.video_url, savePath);
  console.log(savePath);
}

main().catch((error) => {
  console.error(`错误: ${error.message}`);
  process.exit(1);
});


path: references/api.md
content: # Wan Video Generation API Notes

官方文档：

- https://help.aliyun.com/zh/model-studio/text-to-video-api-reference
- https://help.aliyun.com/zh/model-studio/text-to-video-guide
- https://help.aliyun.com/zh/model-studio/use-video-generation

## 当前 skill 使用方式

- 默认模型：`wan2.6-t2v`
- 默认 Endpoint：`POST /api/v1/services/aigc/video-generation/video-synthesis`
- 查询任务：`GET /api/v1/tasks/{task_id}`
- 鉴权：`Authorization: Bearer $DASHSCOPE_API_KEY`
- 异步调用必须加：`X-DashScope-Async: enable`

## 当前脚本的默认策略

- 默认提交异步任务并轮询
- 成功后自动下载 MP4 到本地 `outputs/`
- `--no-wait` 模式下只返回 task_id
- 默认按 `wan2.6-t2v` 的能力集组织参数
- 支持 `--goal` 做 cheap|balanced|quality 的目标驱动选型
- 支持 `--tier` 做档位驱动选型
- 支持 `--quality` + `--ratio` 映射到官方允许的具体 `size`
- 会按不同模型自动限制可用的分辨率档位
- 会基于内置的中国内地价格表显示成本提醒

## 选型与覆盖规则

- 模型选择：`CLI --model > CLI --tier > CLI --goal > config.defaultGoal/config.goal > config.defaultTier/config.tier > config.model > 内置默认`
- 分辨率选择：`CLI --size > CLI (--quality + --ratio) > goal/tier 默认 quality/ratio > 模型默认`
- 时长选择：`CLI --duration > goal/tier 默认 duration > 模型默认`
- `goal` 用于表达用户目标，`tier` 用于表达具体模型档位；显式 `--tier` 会覆盖 `goal`
- `tier` 和 `goal` 都只负责提供默认模型、默认分辨率档位和默认时长，不会锁死 `--quality`、`--ratio`、`--size`、`--duration`
- 推荐把 `cheap|balanced|quality` 的意图映射维护在 `config.json` 的 `goals` 中，把 `draft|standard|final` 的模型与默认 `quality/ratio/duration` 维护在 `config.json` 的 `tiers` 中
- `--dry-run` 会打印本次最终选中的模型、分辨率、时长来源和成本估算，便于 Agent 在正式提交前确认

## 推荐的 Agent 调用方式

- 日常调用优先传 `--goal`，不要一开始就硬编码具体模型
- 只有当你明确知道要哪个模型档位时再传 `--tier`
- `--goal balanced` 或 `--tier standard` 之后，仍可显式传 `--quality`、`--ratio`、`--duration` 覆盖默认值
- 如果需要精确控制尺寸，直接传 `--size=宽*高`；一旦传了 `--size`，就不再依赖 `quality+ratio`
- 低成本试探 prompt 时优先使用 `--goal cheap`
- 正式提交前先跑一次 `--dry-run`，确认模型、分辨率、时长和费用提醒

## 关键限制

- 任务与结果链接默认只保留 24 小时
- 视频生成通常需要 1 到 5 分钟
- `wan2.6` / `wan2.5` 默认生成有声视频
- `wan2.2` / `wanx2.1` 默认生成无声视频
- `size` 传给服务端时必须是具体分辨率，例如 `1280*720`
- `duration` 也受模型约束，不是所有模型都支持自由修改秒数


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