| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- #!/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);
- }
- })();
|