gen.mjs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. #!/usr/bin/env node
  2. /**
  3. * skill-video · Veo 3.1 视频生成
  4. * 直连 closeai 上游,绕过 new-api 的 Veo 任务查询 bug
  5. *
  6. * 用法:
  7. * node gen.mjs --prompt "..." [--duration 8] [--ratio 16:9] [--model fast|standard]
  8. * [--resolution 720p|1080p] [--count 1] [--out file.mp4] [--estimate-only]
  9. */
  10. import fs from 'fs';
  11. import path from 'path';
  12. const KEY_FILE = '/opt/data/.fmode/secrets/closeai.key';
  13. const BASE = 'https://api.openai-proxy.org/google';
  14. // ---------- 计价 ----------
  15. const PRICE = {
  16. standard: { model: 'veo-3.1-generate-001', base: 1.38 },
  17. fast: { model: 'veo-3.1-fast-generate-001', base: 0.35 },
  18. };
  19. const MARKUP = 2.0; // 我方倍率(官方 2 倍)
  20. const BASE_SECONDS = 8; // 基础价对应时长
  21. const RES_MULT = { '720p': 1.0, '1080p': 1.8 };
  22. function estimate({ model = 'fast', duration = 8, resolution = '720p', count = 1 }) {
  23. const p = PRICE[model];
  24. const per = p.base * (duration / BASE_SECONDS) * (RES_MULT[resolution] || 1) * MARKUP;
  25. return { per: +per.toFixed(2), total: +(per * count).toFixed(2), model: p.model };
  26. }
  27. // ---------- 参数解析 ----------
  28. function parseArgs(argv) {
  29. const out = {};
  30. for (let i = 2; i < argv.length; i++) {
  31. const a = argv[i];
  32. if (!a.startsWith('--')) continue;
  33. const k = a.slice(2);
  34. const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
  35. out[k] = v;
  36. }
  37. return out;
  38. }
  39. const args = parseArgs(process.argv);
  40. const opts = {
  41. prompt: args.prompt,
  42. duration: Number(args.duration || 8),
  43. ratio: args.ratio || '16:9',
  44. model: args.model === 'standard' ? 'standard' : 'fast',
  45. resolution: args.resolution || '720p',
  46. count: Number(args.count || 1),
  47. out: args.out,
  48. image: args.image,
  49. estimateOnly: !!args['estimate-only'],
  50. };
  51. if (!opts.prompt) {
  52. console.error('缺少 --prompt');
  53. process.exit(1);
  54. }
  55. const est = estimate(opts);
  56. // ---------- 费用前置展示 ----------
  57. console.log('─────────────────────────────────────');
  58. console.log('模型 :', est.model, `(${opts.model})`);
  59. console.log('时长 :', opts.duration + 's');
  60. console.log('分辨率 :', opts.resolution);
  61. console.log('数量 :', opts.count);
  62. console.log('─────────────────────────────────────');
  63. console.log(`预估费用 : $${est.per}/条 → 合计 $${est.total}`);
  64. // 省钱建议
  65. if (opts.model === 'standard' && opts.count > 1) {
  66. const alt = estimate({ ...opts, model: 'fast', count: 1 });
  67. console.log(`省钱建议 : 先用 fast 出 1 条试稿 $${alt.per},确认后再出标准版,可省约 ${Math.round((1 - (alt.per + est.per) / est.total) * 100)}%`);
  68. }
  69. if (opts.duration === 8 && opts.count === 1 && opts.model === 'standard') {
  70. const alt = estimate({ ...opts, model: 'fast' });
  71. console.log(`省钱建议 : 提示词没定稿前用 fast($${alt.per}),省 75%`);
  72. }
  73. console.log('─────────────────────────────────────');
  74. if (opts.estimateOnly) process.exit(0);
  75. // ---------- 调用 ----------
  76. if (!fs.existsSync(KEY_FILE)) {
  77. console.error('凭据缺失:', KEY_FILE);
  78. process.exit(1);
  79. }
  80. const KEY = fs.readFileSync(KEY_FILE, 'utf8').trim();
  81. const modelId = est.model;
  82. async function submit() {
  83. let instance = { prompt: opts.prompt };
  84. if (opts.image) {
  85. const imgBuf = fs.readFileSync(opts.image);
  86. const ext = path.extname(opts.image).toLowerCase();
  87. const mime = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png';
  88. instance.image = { bytesBase64Encoded: imgBuf.toString('base64'), mimeType: mime };
  89. console.error(`图生视频: ${opts.image} (${(imgBuf.length/1024).toFixed(0)}KB, ${mime})`);
  90. }
  91. const body = {
  92. instances: [instance],
  93. parameters: {
  94. aspectRatio: opts.ratio,
  95. durationSeconds: opts.duration,
  96. resolution: opts.resolution,
  97. sampleCount: opts.count,
  98. },
  99. };
  100. const r = await fetch(`${BASE}/v1beta/models/${modelId}:predictLongRunning`, {
  101. method: 'POST',
  102. headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  103. body: JSON.stringify(body),
  104. });
  105. const t = await r.text();
  106. if (!r.ok) throw new Error(`提交失败 ${r.status}: ${t.slice(0, 300)}`);
  107. const j = JSON.parse(t);
  108. if (!j.name) throw new Error('未返回 operation name: ' + t.slice(0, 300));
  109. return j.name;
  110. }
  111. async function poll(opName) {
  112. // ★ 必须 POST + :fetchPredictOperation,GET 会 404
  113. const url = `${BASE}/v1beta/models/${modelId}:fetchPredictOperation`;
  114. for (let i = 0; i < 40; i++) {
  115. await new Promise(r => setTimeout(r, 15000));
  116. const r = await fetch(url, {
  117. method: 'POST',
  118. headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  119. body: JSON.stringify({ operationName: opName }),
  120. });
  121. const t = await r.text();
  122. if (!r.ok) {
  123. process.stdout.write(`[${(i + 1) * 15}s] HTTP ${r.status}\n`);
  124. continue;
  125. }
  126. let j;
  127. try { j = JSON.parse(t); } catch { continue; }
  128. if (j.done) return j;
  129. process.stdout.write(`[${(i + 1) * 15}s] 生成中...\n`);
  130. }
  131. throw new Error('超过 10 分钟未完成');
  132. }
  133. function save(result, outPath) {
  134. const vids = result?.response?.videos || [];
  135. const filtered = result?.response?.raiMediaFilteredCount || 0;
  136. if (!vids.length) {
  137. const reasons = result?.response?.raiMediaFilteredReasons || [];
  138. throw new Error(`无视频返回。安全过滤数=${filtered} 原因=${JSON.stringify(reasons)}`);
  139. }
  140. const dir = path.dirname(outPath);
  141. if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  142. const saved = [];
  143. vids.forEach((v, i) => {
  144. const p = vids.length === 1 ? outPath
  145. : outPath.replace(/\.mp4$/, `-${i + 1}.mp4`);
  146. fs.writeFileSync(p, Buffer.from(v.bytesBase64Encoded, 'base64'));
  147. saved.push({ path: p, kb: Math.round(fs.statSync(p).size / 1024) });
  148. });
  149. return { saved, filtered };
  150. }
  151. (async () => {
  152. try {
  153. console.log('提交任务...');
  154. const op = await submit();
  155. console.log('operation:', op.split('/').pop());
  156. console.log('等待生成(Veo 通常 40-120 秒)...');
  157. const result = await poll(op);
  158. const outPath = opts.out || `/opt/data/发用户/veo-${Date.now()}.mp4`;
  159. const { saved, filtered } = save(result, outPath);
  160. console.log('─────────────────────────────────────');
  161. saved.forEach(s => console.log(`✓ ${s.path} ${s.kb}KB`));
  162. if (filtered) console.log(`⚠ 安全过滤: ${filtered} 条被拦`);
  163. console.log(`实际消耗: 约 $${est.total}`);
  164. console.log('─────────────────────────────────────');
  165. } catch (e) {
  166. console.error('失败:', e.message);
  167. process.exit(1);
  168. }
  169. })();