video.mjs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. // scripts/video.mjs — 图生视频(provider: video)
  2. //
  3. // ⚠️ 现状:平台视频渠道未开通(实测 POST /v1/video/generations 返回 503
  4. // "No available channel for model doubao-seedance-1-0-pro")。
  5. //
  6. // 两条路径:
  7. // · 真通道:provider.call → 异步 task 轮询 → 下载 MP4
  8. // · 兜底(--fallback-still,默认开启于 auto 模式):把关键帧做成 Ken Burns 动态镜头
  9. // 兜底产物文件名带 .fallback 标记,绝不冒充真视频生成结果。
  10. import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
  11. import { join } from 'node:path';
  12. import {
  13. parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download, humanSize,
  14. } from '../lib/index.mjs';
  15. import { getProvider } from '../lib/providers.mjs';
  16. import { buildVideoPrompt, aspectToVideoSize } from '../lib/prompts.mjs';
  17. import { kenBurns, probe as ffprobe, checkFfmpeg } from '../lib/ffmpeg.mjs';
  18. /** 从 provider 结果里提取视频 URL */
  19. function extractVideoUrls(result) {
  20. const urls = [];
  21. const walk = (o, depth = 0) => {
  22. if (!o || depth > 6) return;
  23. if (typeof o === 'string' && /^https?:\/\/.+\.(mp4|mov|webm|mkv)(\?.*)?$/i.test(o)) urls.push(o);
  24. else if (Array.isArray(o)) o.forEach((x) => walk(x, depth + 1));
  25. else if (typeof o === 'object') {
  26. for (const [k, v] of Object.entries(o)) {
  27. if (/video_url|url|video|download/i.test(k) && typeof v === 'string' && /^https?:\/\/.+\.(mp4|mov|webm)/i.test(v)) urls.push(v);
  28. else walk(v, depth + 1);
  29. }
  30. }
  31. };
  32. walk(result);
  33. return [...new Set(urls)];
  34. }
  35. /**
  36. * 为分镜里的镜头生成视频片段。
  37. * @param {object} o
  38. * @param {object} o.storyboard
  39. * @param {string} [o.keyframesDir]
  40. * @param {'auto'|'provider'|'still'} [o.mode]
  41. * @param {number} [o.shotId]
  42. * @param {number} [o.limit]
  43. * @param {boolean} [o.force] 兜底模式下也重跑已存在的文件
  44. */
  45. export async function generateClips(o = {}) {
  46. const {
  47. storyboard,
  48. keyframesDir = paths.frames,
  49. clipsDir = paths.clips,
  50. mode = 'auto',
  51. shotId,
  52. limit,
  53. force = false,
  54. aspect = storyboard?.aspect || '16:9',
  55. onProviderError,
  56. } = o;
  57. if (!storyboard) throw new Error('generateClips 需要 storyboard');
  58. mkdirSync(clipsDir, { recursive: true });
  59. const size = aspectToVideoSize(aspect);
  60. const provider = getProvider('video');
  61. // 决定用哪条路:auto 模式下先探活,不通就直接走兜底,避免每镜都白等
  62. let useProvider = mode === 'provider';
  63. let probeNote = null;
  64. if (mode === 'auto') {
  65. const p = await provider.probe({ deep: false });
  66. useProvider = p.available === true;
  67. probeNote = p.detail;
  68. if (!useProvider) {
  69. log.warn(`视频通道不可用:${p.detail}`);
  70. log.warn('转为兜底模式:关键帧 + Ken Burns 运镜(产物带 .fallback 标记,非真视频生成)');
  71. }
  72. }
  73. if (mode === 'still') useProvider = false;
  74. let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
  75. if (limit) shots = shots.slice(0, Number(limit));
  76. const results = [];
  77. for (const shot of shots) {
  78. const name = `shot-${String(shot.id).padStart(2, '0')}`;
  79. const still = join(keyframesDir, `${name}.png`);
  80. const out = join(clipsDir, `${name}.mp4`);
  81. const vPrompt = buildVideoPrompt(shot);
  82. if (existsSync(out) && !force) {
  83. log.dim(`[${name}] 已存在,跳过(--force 覆盖)`);
  84. results.push({ id: shot.id, file: out, status: 'skipped', mode: useProvider ? 'provider' : 'still' });
  85. continue;
  86. }
  87. if (useProvider) {
  88. if (!existsSync(still)) {
  89. log.err(`[${name}] 缺少关键帧 ${still},先跑 fmode-mv keyframe`);
  90. results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
  91. continue;
  92. }
  93. try {
  94. log.info(`[${name}] 图生视频(${shot.duration}s,${size})…`);
  95. const { taskId, result } = await provider.call({
  96. image: still,
  97. prompt: vPrompt,
  98. duration: Math.min(8, Math.max(3, Math.round(shot.duration))),
  99. size,
  100. });
  101. const urls = extractVideoUrls(result);
  102. if (!urls.length) throw new Error(`任务成功但未返回视频 URL:${JSON.stringify(result).slice(0, 300)}`);
  103. const buf = await download(urls[0]);
  104. writeFileSync(out, buf);
  105. log.ok(`→ ${out}(${humanSize(buf.length)})`);
  106. results.push({ id: shot.id, file: out, status: 'ok', mode: 'provider', taskId, bytes: buf.length });
  107. } catch (e) {
  108. log.err(`[${name}] 视频通道失败:${e.message.split('\n')[0]}`);
  109. if (onProviderError) onProviderError(e, shot);
  110. results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'provider' });
  111. }
  112. continue;
  113. }
  114. // ---- 兜底:Ken Burns ----
  115. if (!existsSync(still)) {
  116. log.err(`[${name}] 缺少关键帧 ${still},先跑 fmode-mv keyframe`);
  117. results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
  118. continue;
  119. }
  120. const fbOut = join(clipsDir, `${name}.fallback.mp4`);
  121. const dir = shot.id % 3 === 0 ? 'out' : 'in';
  122. try {
  123. await kenBurns(still, fbOut, {
  124. duration: shot.duration,
  125. width: Number(String(size).split('x')[0]),
  126. height: Number(String(size).split('x')[1]),
  127. direction: dir,
  128. zoom: 0.1,
  129. });
  130. const st = statSync(fbOut);
  131. log.ok(`→ ${fbOut}(${humanSize(st.size)},Ken Burns 兜底)`);
  132. results.push({ id: shot.id, file: fbOut, status: 'ok', mode: 'still', bytes: st.size, fallback: true });
  133. } catch (e) {
  134. log.err(`[${name}] 兜底生成失败:${e.message.split('\n')[0]}`);
  135. results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'still' });
  136. }
  137. }
  138. writeJSON(join(clipsDir, 'clips.json'), {
  139. mode: useProvider ? 'provider' : 'still',
  140. probeNote,
  141. size,
  142. clips: results,
  143. _meta: { createdBy: 'fmode-mv/video', generatedAt: new Date().toISOString() },
  144. });
  145. return { results, mode: useProvider ? 'provider' : 'still', clipsDir };
  146. }
  147. export async function main(argv = process.argv.slice(2)) {
  148. const { _, flags } = parseArgs(argv);
  149. if (flags.help || !flags.from) {
  150. console.log(`
  151. fmode-mv video — 图生视频(video 通道)
  152. 用法:
  153. fmode-mv video --from work/storyboard.json # auto:能走通道就走,否则兜底
  154. fmode-mv video --from work/storyboard.json --mode provider # 只走真通道(不通即失败)
  155. fmode-mv video --from work/storyboard.json --mode still # 只用关键帧做 Ken Burns 兜底
  156. fmode-mv video --from work/storyboard.json --shot 3
  157. 选项:
  158. --from <file> 分镜 JSON
  159. --mode auto|provider|still 默认 auto
  160. --shot <id> 只做指定镜头
  161. --limit <n> 最多 n 镜
  162. --frames <dir> 关键帧目录(默认 work/frames)
  163. --out <dir> 输出目录(默认 work/clips)
  164. --model <name> 覆盖视频模型
  165. --force 覆盖已存在的片段
  166. ⚠️ 现状:视频渠道未开通(upstream 无可用渠道)。
  167. auto 模式会自动降级为「静帧 + Ken Burns 运镜」,产物名带 .fallback,
  168. 用于先出片、后替换。真通道开通后 auto 会自动切回。
  169. `);
  170. return 0;
  171. }
  172. checkFfmpeg();
  173. ensureWork();
  174. const from = String(flags.from);
  175. if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
  176. const storyboard = readJSON(from);
  177. log.step(`生成视频片段:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
  178. const r = await generateClips({
  179. storyboard,
  180. keyframesDir: String(flags.frames || paths.frames),
  181. clipsDir: String(flags.out || paths.clips),
  182. mode: flags.mode || 'auto',
  183. shotId: flags.shot ? Number(flags.shot) : undefined,
  184. limit: flags.limit ? Number(flags.limit) : undefined,
  185. force: !!flags.force,
  186. });
  187. const ok = r.results.filter((x) => x.status === 'ok').length;
  188. const fb = r.results.filter((x) => x.fallback).length;
  189. const skipped = r.results.filter((x) => x.status === 'skipped').length;
  190. const failed = r.results.filter((x) => x.status === 'failed').length;
  191. log.key('模式', r.mode === 'provider' ? '真通道' : '兜底(Ken Burns)');
  192. log.key('结果', `成功 ${ok}(其中兜底 ${fb})/ 跳过 ${skipped} / 失败 ${failed}`);
  193. if (r.mode === 'still' && fb > 0) {
  194. console.log('');
  195. log.warn('注意:本次所有片段都是「静帧 + 运镜」兜底产物,不是 AI 图生视频结果。');
  196. log.dim('真通道开通后重跑 `fmode-mv video --from work/storyboard.json --mode provider --force` 即可替换。');
  197. }
  198. return failed > 0 && ok === 0 ? 1 : 0;
  199. }
  200. if (import.meta.url === `file://${process.argv[1]}`) {
  201. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  202. console.error(`✗ ${e.message}`);
  203. process.exit(1);
  204. });
  205. }
  206. export { ffprobe };