video.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. // scripts/video.mjs — 工序⑥:图生视频(即梦视频 3.0)
  2. //
  3. // ★ 本工序已从「旧网关通道(503 无渠道)+ Ken Burns 兜底」升级为「即梦真实通道」。
  4. // 实测(2026-10-13):/getVideoV3_720p,frames=121(5s),返回 960×960 h264 mp4 1.09MB。
  5. //
  6. // 三条硬规则(来自 KB 的实战踩坑):
  7. // 1. frames = 24 × 秒数 + 1,且接口只接受 [121, 241] → 可用时长就是 5~10 秒
  8. // 2. 第一镜优先 method=3(首尾帧);失败必须自动降级 method=2(首帧)
  9. // 3. 每镜 prompt 必须含「音效描述 + 运镜 + 4k 画质词」
  10. //
  11. // 兜底仍在(--mode still):通道整体不可用时用「静帧 + Ken Burns 运镜」先出片,
  12. // 但产物一律带 .fallback 标记,绝不冒充真 AI 图生视频。
  13. import { existsSync, mkdirSync, appendFileSync, statSync } from 'node:fs';
  14. import { join } from 'node:path';
  15. import {
  16. parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, fileBytes,
  17. CostLedger, buildVideoPrompt, aspectToVideoSize, secondsToFrames, framesToSeconds,
  18. JIMENG, VIDEO_FRAMES, ENDPOINTS, getProvider,
  19. } from '../lib/index.mjs';
  20. import { kenBurns, checkFfmpeg } from '../lib/ffmpeg.mjs';
  21. /** workId 日志(KB 要求:`[时间戳] 类型 | workId: xxx | 内容: xxx`) */
  22. function appendWorkIdLog(logFile, { type, workId, content }) {
  23. try {
  24. mkdirSync(join(logFile, '..'), { recursive: true });
  25. appendFileSync(logFile, `[${new Date().toISOString()}] ${type} | workId: ${workId} | 内容: ${content}\n`, 'utf8');
  26. } catch {
  27. /* 日志失败不影响主流程 */
  28. }
  29. }
  30. /**
  31. * 逐镜生成视频片段。
  32. *
  33. * @param {object} o
  34. * @param {object} o.storyboard
  35. * @param {string} [o.keyframesDir] 关键帧目录(默认 work/frames)
  36. * @param {string} [o.clipsDir] 输出目录(默认 work/clips)
  37. * @param {'auto'|'jimeng'|'still'} [o.mode] 默认 auto(能走即梦就走,否则兜底)
  38. * @param {number} [o.shotId]
  39. * @param {number} [o.limit]
  40. * @param {boolean} [o.force]
  41. * @param {string} [o.resolution] 720p | 1080p | pro
  42. */
  43. export async function generateClips(o = {}) {
  44. const {
  45. storyboard,
  46. keyframesDir = paths.frames,
  47. clipsDir = paths.clips,
  48. mode = 'auto',
  49. shotId,
  50. limit,
  51. force = false,
  52. aspect = storyboard?.aspect || '16:9',
  53. resolution = process.env.FMODE_VIDEO_RESOLUTION || '720p',
  54. voiceoverAudio = '',
  55. workIdLog = join(paths.work, 'workids.log'),
  56. durationOverride = 0,
  57. } = o;
  58. if (!storyboard) throw new Error('generateClips 需要 storyboard');
  59. mkdirSync(clipsDir, { recursive: true });
  60. const size = aspectToVideoSize(aspect);
  61. const provider = getProvider('jimeng');
  62. const ledger = new CostLedger('图生视频');
  63. const epKind = resolution === '1080p' ? 'video1080' : resolution === 'pro' ? 'videoPro' : 'video720';
  64. const unitPrice = ENDPOINTS[epKind].cost;
  65. // ---- 决定走哪条路 ----
  66. let useProvider = mode !== 'still';
  67. let probeNote = null;
  68. if (mode === 'auto' || mode === 'jimeng') {
  69. const p = await provider.probe({ deep: false });
  70. probeNote = p.detail;
  71. if (!p.available) {
  72. log.warn(`即梦视频通道不可用:${p.detail}`);
  73. if (mode === 'jimeng') throw new Error(`即梦通道不可用:${p.detail}`);
  74. log.warn('转为兜底模式:关键帧 + Ken Burns 运镜(产物带 .fallback 标记,非真视频生成)');
  75. useProvider = false;
  76. } else {
  77. log.info(`即梦通道就绪(${p.detail}),分辨率 ${resolution} ¥${unitPrice}/秒`);
  78. }
  79. }
  80. let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
  81. if (limit) shots = shots.slice(0, Number(limit));
  82. // ---- 调用前先给出总费用预估(红线要求) ----
  83. if (useProvider && shots.length) {
  84. let totalSec = 0;
  85. for (const s of shots) totalSec += framesToSeconds(secondsToFrames(durationOverride || s.duration || 5));
  86. const est = totalSec * unitPrice;
  87. log.raw('');
  88. log.raw(` 费用预估:${shots.length} 镜 × 平均 ${(totalSec / shots.length).toFixed(1)}s = ${totalSec.toFixed(1)}s × ¥${unitPrice}/秒 ≈ ¥${est.toFixed(2)}`);
  89. log.raw(' (即梦接口只接受 121~241 帧,即 5~10 秒/镜;单镜时长会就近吸附)');
  90. log.raw('');
  91. }
  92. const results = [];
  93. let firstShotDone = false;
  94. for (const shot of shots) {
  95. const name = `shot-${String(shot.id).padStart(2, '0')}`;
  96. const first = join(keyframesDir, `${name}.png`);
  97. const out = join(clipsDir, `${name}.mp4`);
  98. const vPrompt = buildVideoPrompt(shot);
  99. const rawSeconds = durationOverride || shot.duration || 5;
  100. const frames = secondsToFrames(rawSeconds);
  101. const seconds = framesToSeconds(frames);
  102. const durNote = Math.abs(seconds - rawSeconds) > 0.3 ? `(原 ${rawSeconds}s → 吸附为 ${seconds}s,接口限 5~10s)` : '';
  103. if (existsSync(out) && !force) {
  104. log.dim(`[${name}] 已存在,跳过(--force 覆盖)`);
  105. results.push({ id: shot.id, file: out, status: 'skipped', mode: useProvider ? 'jimeng' : 'still', bytes: fileBytes(out) });
  106. firstShotDone = true;
  107. continue;
  108. }
  109. if (!existsSync(first)) {
  110. log.err(`[${name}] 缺少关键帧 ${first},先跑 fmode-mv scene(或 fmode-mv keyframe)`);
  111. results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
  112. continue;
  113. }
  114. // ---------- 真通道:即梦 ----------
  115. if (useProvider) {
  116. // 第一镜:若能拿到尾帧(即第 2 镜的关键帧)就用 method=3 做首尾帧转场
  117. const lastShot = !firstShotDone && shots.length > 1 ? shots[shots.indexOf(shot) + 1] : null;
  118. const lastFrame = lastShot ? join(keyframesDir, `shot-${String(lastShot.id).padStart(2, '0')}.png`) : null;
  119. const wantMethod = lastFrame && existsSync(lastFrame) ? '3' : '2';
  120. log.info(`[${name}] 图生视频 ${seconds}s(${frames} 帧,method=${wantMethod}${wantMethod === '3' ? ' 首尾帧' : ' 首帧'},${size})${durNote}`);
  121. try {
  122. const r = await provider.img2video({
  123. prompt: vPrompt,
  124. firstImagePath: first,
  125. lastImagePath: lastFrame && existsSync(lastFrame) ? lastFrame : undefined,
  126. seconds,
  127. resolution,
  128. method: wantMethod,
  129. outFile: out,
  130. ledger,
  131. });
  132. const st = statSync(out);
  133. appendWorkIdLog(workIdLog, { type: `video/${resolution}`, workId: r.workId, content: `${name} ${seconds}s method=${r.method}${r.degraded ? '(已从 method=3 降级)' : ''}` });
  134. log.ok(`→ ${out}(${humanSize(st.size)},method=${r.method}${r.degraded ? ' 已降级' : ''},workId=${r.workId})`);
  135. results.push({
  136. id: shot.id,
  137. file: out,
  138. status: 'ok',
  139. mode: 'jimeng',
  140. method: r.method,
  141. degraded: !!r.degraded,
  142. frames: r.frames,
  143. seconds: r.seconds,
  144. workId: r.workId,
  145. bytes: st.size,
  146. });
  147. } catch (e) {
  148. log.err(`[${name}] 即梦图生视频失败:${String(e.message).split('\n')[0]}`);
  149. log.dim('该镜将留空;可用 --mode still 单独为它做兜底,或稍后重跑。');
  150. results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'jimeng' });
  151. }
  152. firstShotDone = true;
  153. continue;
  154. }
  155. // ---------- 兜底:Ken Burns ----------
  156. const fbOut = join(clipsDir, `${name}.fallback.mp4`);
  157. const dir = shot.id % 3 === 0 ? 'out' : 'in';
  158. try {
  159. await kenBurns(first, fbOut, {
  160. duration: seconds,
  161. width: Number(String(size).split('x')[0]),
  162. height: Number(String(size).split('x')[1]),
  163. direction: dir,
  164. zoom: 0.1,
  165. });
  166. const sz = statSync(fbOut).size;
  167. log.ok(`→ ${fbOut}(${humanSize(sz)},Ken Burns 兜底)`);
  168. results.push({ id: shot.id, file: fbOut, status: 'ok', mode: 'still', bytes: sz, fallback: true });
  169. } catch (e) {
  170. log.err(`[${name}] 兜底生成失败:${String(e.message).split('\n')[0]}`);
  171. results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'still' });
  172. }
  173. firstShotDone = true;
  174. }
  175. const out = {
  176. mode: useProvider ? 'jimeng' : 'still',
  177. resolution,
  178. probeNote,
  179. unitPrice,
  180. size,
  181. clips: results,
  182. cost: { total: ledger.total, entries: ledger.entries },
  183. workIdLog,
  184. _meta: { createdBy: 'fmode-mv/video', generatedAt: new Date().toISOString() },
  185. };
  186. writeJSON(join(clipsDir, 'clips.json'), out);
  187. return { results, mode: out.mode, resolution, clipsDir, cost: out.cost, ledger };
  188. }
  189. export async function main(argv = process.argv.slice(2)) {
  190. const { flags } = parseArgs(argv);
  191. if (flags.help || !flags.from) {
  192. console.log(`
  193. fmode-mv video — 图生视频(工序⑥,即梦视频 3.0,✅ 实测可用)
  194. 用法:
  195. fmode-mv video --from work/storyboard.json # auto:走即梦真通道
  196. fmode-mv video --from work/storyboard.json --shot 1 --limit 1 # 只做 1 镜(最省,5s ≈ ¥1.4)
  197. fmode-mv video --from work/storyboard.json --resolution 1080p # 高清(¥0.63/秒)
  198. fmode-mv video --from work/storyboard.json --mode still # 强制 Ken Burns 兜底(零 API 费用)
  199. fmode-mv video --from work/storyboard.json --force # 覆盖已存在片段
  200. 选项:
  201. --from <file> 分镜 JSON
  202. --mode auto|jimeng|still 默认 auto(通道不通自动降级为兜底)
  203. --resolution 720p|1080p|pro 默认 720p(¥0.28/秒;1080p ¥0.63;pro ¥1)
  204. --shot <id> 只做指定镜头
  205. --limit <n> 只做前 n 镜(试跑省钱)
  206. --frames <dir> 关键帧目录(默认 work/frames)
  207. --out <dir> 输出目录(默认 work/clips)
  208. --duration <秒> 覆盖单镜时长(会吸附到接口允许的 5~10 秒)
  209. --force 覆盖已存在的片段
  210. 硬规则(已内置,无需手动处理):
  211. · frames = 24 × 秒数 + 1,接口只接受 [121, 241] → 单镜实际 5~10 秒
  212. · 第一镜优先 method=3(首尾帧,用下一镜的关键帧作尾帧);失败自动降级 method=2
  213. · 每镜 prompt 含「音效 + 运镜 + 4k 画质词」
  214. 产出:
  215. work/clips/shot-XX.mp4(兜底产物为 shot-XX.fallback.mp4)
  216. work/clips/clips.json(每镜 method / workId / 费用 / 是否降级)
  217. work/workids.log(workId 台账,格式:[时间戳] 类型 | workId: xxx | 内容: xxx)
  218. 费用参考: 720p ¥0.28/秒(5 秒镜 ≈ ¥1.41,实测);整片 8 镜 ≈ ¥11
  219. `);
  220. return 0;
  221. }
  222. checkFfmpeg();
  223. ensureWork();
  224. const from = String(flags.from);
  225. if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
  226. const storyboard = readJSON(from);
  227. log.step(`生成视频片段:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
  228. const r = await generateClips({
  229. storyboard,
  230. keyframesDir: String(flags.frames || paths.frames),
  231. clipsDir: String(flags.out || paths.clips),
  232. mode: flags.mode || 'auto',
  233. shotId: flags.shot ? Number(flags.shot) : undefined,
  234. limit: flags.limit ? Number(flags.limit) : undefined,
  235. force: !!flags.force,
  236. resolution: String(flags.resolution || process.env.FMODE_VIDEO_RESOLUTION || '720p'),
  237. durationOverride: Number(flags.duration) || 0,
  238. });
  239. const ok = r.results.filter((x) => x.status === 'ok').length;
  240. const fb = r.results.filter((x) => x.fallback).length;
  241. const skipped = r.results.filter((x) => x.status === 'skipped').length;
  242. const failed = r.results.filter((x) => x.status === 'failed').length;
  243. const degraded = r.results.filter((x) => x.degraded).length;
  244. log.raw('');
  245. log.raw(r.ledger.summary());
  246. log.key('模式', r.mode === 'jimeng' ? `即梦真通道(${r.resolution})` : '兜底(Ken Burns)');
  247. log.key('结果', `成功 ${ok}(兜底 ${fb} / 降级 ${degraded})/ 跳过 ${skipped} / 失败 ${failed}`);
  248. if (r.mode === 'still' && fb > 0) {
  249. log.warn('注意:本次片段是「静帧 + 运镜」兜底产物,不是 AI 图生视频结果。');
  250. }
  251. return failed > 0 && ok === 0 ? 1 : 0;
  252. }
  253. if (import.meta.url === `file://${process.argv[1]}`) {
  254. main()
  255. .then((c) => process.exit(Number(c) || 0))
  256. .catch((e) => {
  257. console.error(`✗ ${e.message}`);
  258. if (process.env.DEBUG) console.error(e.stack);
  259. process.exit(1);
  260. });
  261. }
  262. export { framesToSeconds, secondsToFrames, VIDEO_FRAMES, JIMENG };