|
|
@@ -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);
|
|
|
+ }
|
|
|
+})();
|