gen.mjs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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. estimateOnly: !!args['estimate-only'],
  49. };
  50. if (!opts.prompt) {
  51. console.error('缺少 --prompt');
  52. process.exit(1);
  53. }
  54. const est = estimate(opts);
  55. // ---------- 费用前置展示 ----------
  56. console.log('─────────────────────────────────────');
  57. console.log('模型 :', est.model, `(${opts.model})`);
  58. console.log('时长 :', opts.duration + 's');
  59. console.log('分辨率 :', opts.resolution);
  60. console.log('数量 :', opts.count);
  61. console.log('─────────────────────────────────────');
  62. console.log(`预估费用 : $${est.per}/条 → 合计 $${est.total}`);
  63. // 省钱建议
  64. if (opts.model === 'standard' && opts.count > 1) {
  65. const alt = estimate({ ...opts, model: 'fast', count: 1 });
  66. console.log(`省钱建议 : 先用 fast 出 1 条试稿 $${alt.per},确认后再出标准版,可省约 ${Math.round((1 - (alt.per + est.per) / est.total) * 100)}%`);
  67. }
  68. if (opts.duration === 8 && opts.count === 1 && opts.model === 'standard') {
  69. const alt = estimate({ ...opts, model: 'fast' });
  70. console.log(`省钱建议 : 提示词没定稿前用 fast($${alt.per}),省 75%`);
  71. }
  72. console.log('─────────────────────────────────────');
  73. if (opts.estimateOnly) process.exit(0);
  74. // ---------- 调用 ----------
  75. if (!fs.existsSync(KEY_FILE)) {
  76. console.error('凭据缺失:', KEY_FILE);
  77. process.exit(1);
  78. }
  79. const KEY = fs.readFileSync(KEY_FILE, 'utf8').trim();
  80. const modelId = est.model;
  81. async function submit() {
  82. const body = {
  83. instances: [{ prompt: opts.prompt }],
  84. parameters: {
  85. aspectRatio: opts.ratio,
  86. durationSeconds: opts.duration,
  87. resolution: opts.resolution,
  88. sampleCount: opts.count,
  89. },
  90. };
  91. const r = await fetch(`${BASE}/v1beta/models/${modelId}:predictLongRunning`, {
  92. method: 'POST',
  93. headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  94. body: JSON.stringify(body),
  95. });
  96. const t = await r.text();
  97. if (!r.ok) throw new Error(`提交失败 ${r.status}: ${t.slice(0, 300)}`);
  98. const j = JSON.parse(t);
  99. if (!j.name) throw new Error('未返回 operation name: ' + t.slice(0, 300));
  100. return j.name;
  101. }
  102. async function poll(opName) {
  103. // ★ 必须 POST + :fetchPredictOperation,GET 会 404
  104. const url = `${BASE}/v1beta/models/${modelId}:fetchPredictOperation`;
  105. for (let i = 0; i < 40; i++) {
  106. await new Promise(r => setTimeout(r, 15000));
  107. const r = await fetch(url, {
  108. method: 'POST',
  109. headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  110. body: JSON.stringify({ operationName: opName }),
  111. });
  112. const t = await r.text();
  113. if (!r.ok) {
  114. process.stdout.write(`[${(i + 1) * 15}s] HTTP ${r.status}\n`);
  115. continue;
  116. }
  117. let j;
  118. try { j = JSON.parse(t); } catch { continue; }
  119. if (j.done) return j;
  120. process.stdout.write(`[${(i + 1) * 15}s] 生成中...\n`);
  121. }
  122. throw new Error('超过 10 分钟未完成');
  123. }
  124. function save(result, outPath) {
  125. const vids = result?.response?.videos || [];
  126. const filtered = result?.response?.raiMediaFilteredCount || 0;
  127. if (!vids.length) {
  128. const reasons = result?.response?.raiMediaFilteredReasons || [];
  129. throw new Error(`无视频返回。安全过滤数=${filtered} 原因=${JSON.stringify(reasons)}`);
  130. }
  131. const dir = path.dirname(outPath);
  132. if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  133. const saved = [];
  134. vids.forEach((v, i) => {
  135. const p = vids.length === 1 ? outPath
  136. : outPath.replace(/\.mp4$/, `-${i + 1}.mp4`);
  137. fs.writeFileSync(p, Buffer.from(v.bytesBase64Encoded, 'base64'));
  138. saved.push({ path: p, kb: Math.round(fs.statSync(p).size / 1024) });
  139. });
  140. return { saved, filtered };
  141. }
  142. (async () => {
  143. try {
  144. console.log('提交任务...');
  145. const op = await submit();
  146. console.log('operation:', op.split('/').pop());
  147. console.log('等待生成(Veo 通常 40-120 秒)...');
  148. const result = await poll(op);
  149. const outPath = opts.out || `/opt/data/发用户/veo-${Date.now()}.mp4`;
  150. const { saved, filtered } = save(result, outPath);
  151. console.log('─────────────────────────────────────');
  152. saved.forEach(s => console.log(`✓ ${s.path} ${s.kb}KB`));
  153. if (filtered) console.log(`⚠ 安全过滤: ${filtered} 条被拦`);
  154. console.log(`实际消耗: 约 $${est.total}`);
  155. console.log('─────────────────────────────────────');
  156. } catch (e) {
  157. console.error('失败:', e.message);
  158. process.exit(1);
  159. }
  160. })();