#!/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, image: args.image, 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() { let instance = { prompt: opts.prompt }; if (opts.image) { const imgBuf = fs.readFileSync(opts.image); const ext = path.extname(opts.image).toLowerCase(); const mime = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png'; instance.image = { bytesBase64Encoded: imgBuf.toString('base64'), mimeType: mime }; console.error(`图生视频: ${opts.image} (${(imgBuf.length/1024).toFixed(0)}KB, ${mime})`); } const body = { instances: [instance], 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); } })();