Преглед на файлове

feat: Veo 3.1 视频生成技能 v1.0

- 一条命令完成费用预估→提交→轮询→下载
- 绕过 new-api 的 GET 查询 bug,直连上游 closeai
- 内置费用计算与四条省钱策略(fast 试稿省 75%)
- 实测通过:8s标准版 2.6MB / 4s快速版 1.7MB
liuyuyang преди 13 часа
ревизия
4e0438ebfe
променени са 4 файла, в които са добавени 484 реда и са изтрити 0 реда
  1. 4 0
      .gitignore
  2. 91 0
      README.md
  3. 213 0
      SKILL.md
  4. 176 0
      scripts/gen.mjs

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+node_modules/
+*.mp4
+*.log
+.DS_Store

+ 91 - 0
README.md

@@ -0,0 +1,91 @@
+# skill-video · Veo 3.1 视频生成技能
+
+用 Google Veo 3.1 生成视频的 Hermes Agent 技能。一条命令完成:**费用预估 → 提交任务 → 轮询状态 → 下载成片**。
+
+## 为什么有这个技能
+
+网关 `api.fmode.cn` 的 Veo 通道存在一个已知 bug:
+
+```javascript
+// new-api: plugins/tasks/google/plugin.js:44
+{ method: "GET", path: "/v1beta/models/:model/operations/:task_id", type: "query" }
+```
+
+Google 要求用 **POST `:fetchPredictOperation`** 查询任务,插件用 GET,导致每次都返回 `upstream task not found (HTTP 404)` —— 任务能创建,但永远查不到结果。
+
+本技能**绕过网关直连上游**,今天就能用。
+
+## 快速开始
+
+```bash
+# 只算钱,不生成
+node scripts/gen.mjs --prompt "a cat" --estimate-only
+
+# 生成一条 4 秒测试片(最省钱)
+node scripts/gen.mjs --prompt "你的画面描述" --model fast --duration 4 --out ./out.mp4
+
+# 定稿后出高清长片
+node scripts/gen.mjs --prompt "你的画面描述" --model standard --duration 8 --resolution 1080p --out ./final.mp4
+```
+
+## 参数
+
+| 参数 | 可选值 | 默认 | 费用影响 |
+|---|---|---|---|
+| `--prompt` | 文本描述(英文更稳) | 必填 | — |
+| `--model` | `standard` / `fast` | fast | **fast 便宜 75%** |
+| `--duration` | `4` / `6` / `8` | 8 | **时间翻倍 = 费用翻倍** |
+| `--ratio` | `16:9` / `9:16` | 16:9 | 不影响 |
+| `--resolution` | `720p` / `1080p` | 720p | **1080p 贵 1.8 倍** |
+| `--count` | `1` - `4` | 1 | **成倍增加** |
+| `--out` | 输出路径 | 自动命名 | — |
+| `--estimate-only` | 只算钱 | — | — |
+
+## 费用
+
+按官方价 2 倍计价:
+
+| 模型 | 基础价(8s/720p) | 2 倍后 |
+|---|---|---|
+| `veo-3.1-generate-001` | $1.38 | **$2.76** |
+| `veo-3.1-fast-generate-001` | $0.35 | **$0.70** |
+
+**技能会在生成前自动报预估费用,并给出省钱替代方案。**
+
+### 四条省钱原则
+
+1. **先 fast 试稿,定稿再上标准版** —— 省 75%
+2. **时长按需压缩** —— 4s 比 8s 便宜一半
+3. **一次多备选不如先出 1 条** —— 方向对了再加数量
+4. **1080p 留到最后** —— 720p 定稿再出高清
+
+## 配置
+
+凭据放在 `/opt/data/.fmode/secrets/closeai.key`(closeai 渠道 key)。
+
+## 网关现状(2026-11-05 实测)
+
+| 类型 | 模型 | 状态 |
+|---|---|---|
+| 图像 | `doubao-seedream-4-0-250828` | ✅ 走网关,正常 |
+| 视频 | `veo-3.1-generate-001` | ⚠️ 网关有 bug,需直连 |
+| 视频 | `veo-3.1-fast-generate-001` | ⚠️ 同上 |
+| 视频 | 即梦 / Seedance / 可灵 / MiniMax / 万相 | ❌ 网关未接入(503) |
+
+> 注意区分:**Seedream 是图像模型**(可用),**Seedance 是视频模型**(网关没有)。
+
+## 实测记录
+
+| 测试 | 参数 | 结果 |
+|---|---|---|
+| 红苹果 | 8s / 720p / standard | H.264 · 1280×720 · 2.6MB |
+| 热汤面 | 4s / 720p / fast | H.264 · 1280×720 · 1.7MB · 15 秒出片 |
+
+## 开发者
+
+- **刘雨飏**(Ryan Liu)· 混沌 AI 领教 · 江西脑控科技有限公司
+
+## 贡献者
+
+- 刘雨飏(Ryan Liu)
+- CB

+ 213 - 0
SKILL.md

@@ -0,0 +1,213 @@
+---
+name: skill-video
+description: 触发词:生成视频、做视频、AI视频、文生视频、图生视频、Veo、Sora。用 Veo 3.1 生成视频,含费用预估与省钱策略(按官方2倍计价)。
+---
+
+# skill-video · Veo 3.1 视频生成
+
+## 一、先看这段(必读)
+
+**网关 `api.fmode.cn/v1/video/generations` 目前不可用。**
+new-api 的 Google Veo 插件(`plugins/tasks/google/plugin.js` 第 44 行)用 GET 查任务,而 Google 要求 POST `:fetchPredictOperation`,所以永远返回 `upstream task not found (HTTP 404)`。
+
+**本技能绕过网关,直连上游 closeai。** 通道已验证可出片(H.264 / 1280×720 / 8s)。
+
+- 上游 base:`https://api.openai-proxy.org/google`
+- 凭据:`/opt/data/.fmode/secrets/closeai.key`(渠道 3 的 key,勿外传)
+- 模型:`veo-3.1-generate-001`(标准)、`veo-3.1-fast-generate-001`(快速)
+
+---
+
+## 二、完整调用流程(三步)
+
+### 第 1 步:提交生成
+
+```bash
+KEY=$(cat /opt/data/.fmode/secrets/closeai.key)
+curl -s -X POST "https://api.openai-proxy.org/google/v1beta/models/veo-3.1-generate-001:predictLongRunning" \
+  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
+  -d '{"instances":[{"prompt":"你的提示词"}],"parameters":{"aspectRatio":"16:9","durationSeconds":8}}'
+```
+
+返回:
+```json
+{"name":"projects/wv-s-gemini0910/locations/global/publishers/google/models/veo-3.1-generate-001/operations/xxxx"}
+```
+
+### 第 2 步:轮询(★ 关键,别用 GET)
+
+```bash
+OP="刚才返回的完整 name"
+curl -s -X POST "https://api.openai-proxy.org/google/v1beta/models/veo-3.1-generate-001:fetchPredictOperation" \
+  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
+  -d "{\"operationName\":\"$OP\"}"
+```
+
+**必须是 POST + `:fetchPredictOperation`。** GET 查会 404。
+
+判断:
+- `"done": false` 或字段不存在 → 继续等(间隔 15-20 秒)
+- `"done": true` → 完成了,取视频
+
+### 第 3 步:取视频
+
+```json
+{"done":true,"response":{"raiMediaFilteredCount":0,"videos":[{"bytesBase64Encoded":"...","mimeType":"video/mp4"}]}}
+```
+
+base64 解码写 mp4。**注意 curl 要设 `--max-time 300`**,视频有 2-3MB,默认超时会截断 JSON。
+
+### 安全过滤
+
+- `raiMediaFilteredCount > 0` → 有内容被安全策略拦了,看 `raiMediaFilteredReasons`
+- 视频为空 = 触发了内容审核,不扣费但也没结果
+
+---
+
+## 三、参数详解
+
+### instances(必填)
+
+| 参数 | 类型 | 说明 |
+|---|---|---|
+| `prompt` | string | 文本提示词,英文效果更稳 |
+| `image` | object | 图生视频:`{"bytesBase64Encoded":"...","mimeType":"image/png"}` |
+
+### parameters(影响费用,重点看)
+
+| 参数 | 可选值 | 默认 | 对费用影响 |
+|---|---|---|---|
+| `aspectRatio` | `16:9` / `9:16` | 16:9 | 不影响 |
+| `durationSeconds` | **4 / 6 / 8** | 8 | **★ 核心变量,按秒计费** |
+| `resolution` | `720p` / `1080p` | 720p | 1080p 更贵 |
+| `sampleCount` | 1-4 | 1 | **★ 成倍增加费用** |
+| `generateAudio` | true/false | — | 可能额外计费 |
+| `negativePrompt` | string | — | 不影响 |
+| `personGeneration` | `allow_adult` / `dont_allow` | — | 含人物时注意合规 |
+
+---
+
+## 四、费用计算(★ 技能必须自动算给用户看)
+
+### 计价基准
+
+| 模型 | 官方基础价 | 我方倍率 | 实际扣费 |
+|---|---|---|---|
+| veo-3.1-generate-001 | **$1.38 / 次(8s, 720p)** | 2.0× | **$2.76** |
+| veo-3.1-fast-generate-001 | **$0.35 / 次(8s, 720p)** | 2.0× | **$0.70** |
+
+> **倍率说明**:new-api 渠道当前 `group_ratio=2.5`,用户要求按 **官方 2 倍** 计。技能按 2.0 报预算,实际扣费以网关为准。
+
+### 估算公式
+
+```
+单条费用 ≈ 基础价 × (durationSeconds / 8) × (1080p ? 1.8 : 1.0) × sampleCount × 倍率
+```
+
+### 常用组合预算表(美元,已含 2 倍)
+
+| 组合 | 标准版 | 快速版 |
+|---|---|---|
+| 8s · 720p · 1条 | $2.76 | $0.70 |
+| 8s · 720p · 4条 | $11.04 | $2.80 |
+| 4s · 720p · 1条 | $1.38 | $0.35 |
+| 8s · 1080p · 1条 | $4.97 | $1.26 |
+| 6s · 720p · 1条 | $2.07 | $0.53 |
+
+### ★ 省钱四原则(技能要主动建议)
+
+1. **先 fast 试稿,定稿再用标准版** —— 省 75%。提示词没调好前,fast 足够看效果
+2. **时长按需缩短** —— 4s 比 8s 便宜一半。叙事能压到 4s 就压
+3. **一次多备选不如先出 1 条** —— 确认方向对了再加 `sampleCount`
+4. **1080p 留到最后** —— 720p 定稿再出高清,差 1.8 倍
+
+### 技能必须做的费用前置动作
+
+**任何一次生成前,先报预估费用和替代方案:**
+
+```
+本次预估:8s · 720p · 标准版 · 1条 = $2.76(约 ¥20)
+省钱替代:先用 fast 试稿 $0.70,确认后再用标准版 = 合计 $3.46(比直接出 2 条标准版省 37%)
+是否继续?
+```
+
+---
+
+## 五、常见错误
+
+| 报错 | 原因 | 解决 |
+|---|---|---|
+| `upstream task not found (HTTP 404)` | 用 GET 查了 | 改 POST `:fetchPredictOperation` |
+| `Invalid task protocol request` | 走了网关 `/v1/videos` | 直连上游,用 `/v1beta/models/{m}:predictLongRunning` |
+| `json.decoder.JSONDecodeError` | curl 超时截断 | `--max-time 300` |
+| `raiMediaFilteredCount > 0` | 触发安全审核 | 改提示词,避开人物/暴力/违禁 |
+| 401 | key 不对 | 重取 `/opt/data/.fmode/secrets/closeai.key` |
+
+---
+
+## 六、提示词写法
+
+Veo 对英文更敏感。结构:
+
+```
+[主体] + [动作] + [环境] + [镜头] + [光线] + [风格]
+```
+
+例:
+```
+A golden retriever running on the beach at sunset, tracking shot,
+warm golden hour light, cinematic, shallow depth of field, 4k
+```
+
+**中文提示词也能用,但英文出片质量更稳。** 技能默认把中文需求翻成英文再提交。
+
+---
+
+## 七、脚本
+
+`scripts/gen.mjs` —— 一条命令完成 提交→轮询→下载:
+
+```bash
+node scripts/gen.mjs --prompt "..." --duration 8 --ratio 16:9 --model fast --out /path/to.mp4
+```
+
+参数:
+- `--prompt` 必填
+- `--duration` 4/6/8(默认 8)
+- `--ratio` 16:9 / 9:16
+- `--model` standard / fast(默认 fast,省钱)
+- `--resolution` 720p / 1080p
+- `--count` 1-4(默认 1)
+- `--estimate-only` 只算钱不生成
+---
+
+## 八、网关支持情况的实测结论(2026-11-05 验证)
+
+**网关 `api.fmode.cn` 的视频模型只有 2 个,且当前都不可用:**
+
+| 模型 | 状态 |
+|---|---|
+| `veo-3.1-generate-001` | ❌ 任务查询 404(插件 bug) |
+| `veo-3.1-fast-generate-001` | ❌ 同上 |
+
+**以下模型网关均未接入**(全部返回 503,不是名字写错,是通道不存在):
+
+`jimeng-3.0` / `jimeng-video` / `seedance-1.0` / `seedance-pro` /
+`doubao-seedance-1-0-pro` / `doubao-seedance-1-0-lite` /
+`kling-v2` / `minimax-hailuo-02` / `wan-2.5`
+
+> 有人会说「即梦的 Seedance」,注意区分:
+> **Seedream(doubao-seedream-4-0)是图像模型,网关支持且正常**(走 `/v1/images/generations`)。
+> **Seedance 是视频模型,网关没有。**
+
+**结论:视频生成只能用 Veo 3.1,且必须直连上游 closeai,不能走网关。**
+
+### 图像走网关,视频走上游
+
+如果任务需要「图片 + 视频」混合:
+
+| 类型 | 走哪里 | 端点 |
+|---|---|---|
+| 图像 | 网关(走计费) | `POST https://api.fmode.cn/v1/images/generations`,模型 `doubao-seedream-4-0-250828` |
+| 视频 | 上游直连(不走计费) | 本文档第二节流程 |
+

+ 176 - 0
scripts/gen.mjs

@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+/**
+ * skill-video · Veo 3.1 视频生成
+ * 直连 closeai 上游,绕过 new-api 的 Veo 任务查询 bug
+ *
+ * 用法:
+ *   node gen.mjs --prompt "..." [--duration 8] [--ratio 16:9] [--model fast|standard]
+ *                [--resolution 720p|1080p] [--count 1] [--out file.mp4] [--estimate-only]
+ */
+
+import fs from 'fs';
+import path from 'path';
+
+const KEY_FILE = '/opt/data/.fmode/secrets/closeai.key';
+const BASE = 'https://api.openai-proxy.org/google';
+
+// ---------- 计价 ----------
+const PRICE = {
+  standard: { model: 'veo-3.1-generate-001', base: 1.38 },
+  fast:     { model: 'veo-3.1-fast-generate-001', base: 0.35 },
+};
+const MARKUP = 2.0;            // 我方倍率(官方 2 倍)
+const BASE_SECONDS = 8;        // 基础价对应时长
+const RES_MULT = { '720p': 1.0, '1080p': 1.8 };
+
+function estimate({ model = 'fast', duration = 8, resolution = '720p', count = 1 }) {
+  const p = PRICE[model];
+  const per = p.base * (duration / BASE_SECONDS) * (RES_MULT[resolution] || 1) * MARKUP;
+  return { per: +per.toFixed(2), total: +(per * count).toFixed(2), model: p.model };
+}
+
+// ---------- 参数解析 ----------
+function parseArgs(argv) {
+  const out = {};
+  for (let i = 2; i < argv.length; i++) {
+    const a = argv[i];
+    if (!a.startsWith('--')) continue;
+    const k = a.slice(2);
+    const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
+    out[k] = v;
+  }
+  return out;
+}
+
+const args = parseArgs(process.argv);
+const opts = {
+  prompt: args.prompt,
+  duration: Number(args.duration || 8),
+  ratio: args.ratio || '16:9',
+  model: args.model === 'standard' ? 'standard' : 'fast',
+  resolution: args.resolution || '720p',
+  count: Number(args.count || 1),
+  out: args.out,
+  estimateOnly: !!args['estimate-only'],
+};
+
+if (!opts.prompt) {
+  console.error('缺少 --prompt');
+  process.exit(1);
+}
+
+const est = estimate(opts);
+
+// ---------- 费用前置展示 ----------
+console.log('─────────────────────────────────────');
+console.log('模型     :', est.model, `(${opts.model})`);
+console.log('时长     :', opts.duration + 's');
+console.log('分辨率   :', opts.resolution);
+console.log('数量     :', opts.count);
+console.log('─────────────────────────────────────');
+console.log(`预估费用 : $${est.per}/条  →  合计 $${est.total}`);
+
+// 省钱建议
+if (opts.model === 'standard' && opts.count > 1) {
+  const alt = estimate({ ...opts, model: 'fast', count: 1 });
+  console.log(`省钱建议 : 先用 fast 出 1 条试稿 $${alt.per},确认后再出标准版,可省约 ${Math.round((1 - (alt.per + est.per) / est.total) * 100)}%`);
+}
+if (opts.duration === 8 && opts.count === 1 && opts.model === 'standard') {
+  const alt = estimate({ ...opts, model: 'fast' });
+  console.log(`省钱建议 : 提示词没定稿前用 fast($${alt.per}),省 75%`);
+}
+console.log('─────────────────────────────────────');
+
+if (opts.estimateOnly) process.exit(0);
+
+// ---------- 调用 ----------
+if (!fs.existsSync(KEY_FILE)) {
+  console.error('凭据缺失:', KEY_FILE);
+  process.exit(1);
+}
+const KEY = fs.readFileSync(KEY_FILE, 'utf8').trim();
+const modelId = est.model;
+
+async function submit() {
+  const body = {
+    instances: [{ prompt: opts.prompt }],
+    parameters: {
+      aspectRatio: opts.ratio,
+      durationSeconds: opts.duration,
+      resolution: opts.resolution,
+      sampleCount: opts.count,
+    },
+  };
+  const r = await fetch(`${BASE}/v1beta/models/${modelId}:predictLongRunning`, {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  });
+  const t = await r.text();
+  if (!r.ok) throw new Error(`提交失败 ${r.status}: ${t.slice(0, 300)}`);
+  const j = JSON.parse(t);
+  if (!j.name) throw new Error('未返回 operation name: ' + t.slice(0, 300));
+  return j.name;
+}
+
+async function poll(opName) {
+  // ★ 必须 POST + :fetchPredictOperation,GET 会 404
+  const url = `${BASE}/v1beta/models/${modelId}:fetchPredictOperation`;
+  for (let i = 0; i < 40; i++) {
+    await new Promise(r => setTimeout(r, 15000));
+    const r = await fetch(url, {
+      method: 'POST',
+      headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ operationName: opName }),
+    });
+    const t = await r.text();
+    if (!r.ok) {
+      process.stdout.write(`[${(i + 1) * 15}s] HTTP ${r.status}\n`);
+      continue;
+    }
+    let j;
+    try { j = JSON.parse(t); } catch { continue; }
+    if (j.done) return j;
+    process.stdout.write(`[${(i + 1) * 15}s] 生成中...\n`);
+  }
+  throw new Error('超过 10 分钟未完成');
+}
+
+function save(result, outPath) {
+  const vids = result?.response?.videos || [];
+  const filtered = result?.response?.raiMediaFilteredCount || 0;
+  if (!vids.length) {
+    const reasons = result?.response?.raiMediaFilteredReasons || [];
+    throw new Error(`无视频返回。安全过滤数=${filtered} 原因=${JSON.stringify(reasons)}`);
+  }
+  const dir = path.dirname(outPath);
+  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+  const saved = [];
+  vids.forEach((v, i) => {
+    const p = vids.length === 1 ? outPath
+      : outPath.replace(/\.mp4$/, `-${i + 1}.mp4`);
+    fs.writeFileSync(p, Buffer.from(v.bytesBase64Encoded, 'base64'));
+    saved.push({ path: p, kb: Math.round(fs.statSync(p).size / 1024) });
+  });
+  return { saved, filtered };
+}
+
+(async () => {
+  try {
+    console.log('提交任务...');
+    const op = await submit();
+    console.log('operation:', op.split('/').pop());
+    console.log('等待生成(Veo 通常 40-120 秒)...');
+    const result = await poll(op);
+    const outPath = opts.out || `/opt/data/发用户/veo-${Date.now()}.mp4`;
+    const { saved, filtered } = save(result, outPath);
+    console.log('─────────────────────────────────────');
+    saved.forEach(s => console.log(`✓ ${s.path}  ${s.kb}KB`));
+    if (filtered) console.log(`⚠ 安全过滤: ${filtered} 条被拦`);
+    console.log(`实际消耗: 约 $${est.total}`);
+    console.log('─────────────────────────────────────');
+  } catch (e) {
+    console.error('失败:', e.message);
+    process.exit(1);
+  }
+})();