pipeline-a.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. // scripts/pipeline-a.mjs — 产线A 编排:AI 单曲 MV 端到端
  2. //
  3. // 主题 → 作曲填词 → AI 音乐 → 人声/伴奏分离 → 关键帧 → 图生视频 → 唇形 → 剪辑成片
  4. //
  5. // 编排原则:
  6. // · 每一步都能单独跑(对应 fmode-mv <step>),pipeline 只是串起来;
  7. // · 通道不可用的步骤「如实跳过并记录原因」,能兜底的就兜底(视频→Ken Burns);
  8. // · 最后打印一张「步骤执行表」,一眼看清哪些是真产物、哪些是兜底/跳过。
  9. import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
  10. import { join } from 'node:path';
  11. import {
  12. parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, DEFAULT_ANCHOR,
  13. } from '../lib/index.mjs';
  14. import { buildLyrics } from './lyrics.mjs';
  15. import { buildStoryboardFromSong, renderStoryboardMd } from './storyboard.mjs';
  16. import { generateKeyframes } from './keyframe.mjs';
  17. import { generateScenes } from './scene.mjs';
  18. import { generateClips } from './video.mjs';
  19. import { assemble } from './assemble.mjs';
  20. import { getProvider } from '../lib/providers.mjs';
  21. import { probe as ffprobe, listMedia } from '../lib/ffmpeg.mjs';
  22. export async function runPipelineA(theme, opts = {}) {
  23. const {
  24. title = '',
  25. genre = '',
  26. mood = '',
  27. limit = 0,
  28. aspect = '16:9',
  29. shots = 8,
  30. perShot = 6,
  31. outFile = join(paths.out, 'mv.mp4'),
  32. music = '',
  33. skipMusic = false,
  34. skipVideo = false,
  35. dryRun = false,
  36. // v0.2 新增
  37. characterSheetFile = '',
  38. skipCharacter = false,
  39. characterSteps = 4,
  40. single = false,
  41. tier = '1k',
  42. videoMode = 'auto',
  43. } = opts;
  44. ensureWork();
  45. const steps = [];
  46. const mark = (name, status, detail) => {
  47. steps.push({ name, status, detail });
  48. return status;
  49. };
  50. // ---------- 1. 作曲填词 ----------
  51. log.step('[1/8] 作曲填词');
  52. let song;
  53. if (existsSync(paths.song) && opts.resume !== false) {
  54. song = readJSON(paths.song);
  55. mark('作曲填词', 'reused', `沿用 work/song.json《${song.title}》`);
  56. log.ok(`沿用已有:《${song.title}》`);
  57. } else {
  58. song = await buildLyrics(theme, { genre, mood });
  59. writeJSON(paths.song, song);
  60. mark('作曲填词', 'ok', `《${song.title}》`);
  61. log.ok(`《${song.title}》(${song.genre || '-'} / ${song.bpm || '?'}BPM)`);
  62. }
  63. // ---------- 2. 分镜 ----------
  64. log.step('[2/8] 分镜脚本');
  65. const sb = await buildStoryboardFromSong(song, { shotCount: shots, aspect, perShot });
  66. writeJSON(paths.storyboard, sb);
  67. writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
  68. mark('分镜脚本', 'ok', `${sb.shots.length} 镜 / ${sb.totalDuration}s`);
  69. log.ok(`${sb.shots.length} 个镜头,总时长 ${sb.totalDuration}s`);
  70. // ---------- 3. AI 音乐 ----------
  71. log.step('[3/8] AI 音乐生成');
  72. let musicFile = music && existsSync(music) ? music : null;
  73. if (musicFile) {
  74. mark('AI 音乐', 'provided', `使用外部提供:${musicFile}`);
  75. log.ok(`使用指定音乐:${musicFile}`);
  76. } else if (skipMusic) {
  77. mark('AI 音乐', 'skipped', '--skip-music');
  78. log.warn('按参数跳过');
  79. } else {
  80. const p = getProvider('music');
  81. const pr = await p.probe({ deep: false });
  82. if (pr.available === false) {
  83. mark('AI 音乐', 'unavailable', pr.detail.split('\n')[0]);
  84. log.warn(`通道未开通,跳过:${pr.detail}`);
  85. log.dim('替代:--music <你的音频文件> / 等平台开通后重跑 / 自建兼容端点');
  86. } else {
  87. try {
  88. const { generateMusic } = await import('./music.mjs');
  89. const r = await generateMusic({ prompt: song.stylePrompt, lyrics: song.lyrics, title: song.title });
  90. musicFile = r.files[0].path;
  91. mark('AI 音乐', 'ok', musicFile);
  92. log.ok(musicFile);
  93. } catch (e) {
  94. mark('AI 音乐', 'failed', e.message.split('\n')[0]);
  95. log.err(`失败:${e.message.split('\n')[0]}`);
  96. }
  97. }
  98. }
  99. // ---------- 4. 音轨分离 ----------
  100. log.step('[4/8] 人声 / 伴奏分离');
  101. if (musicFile) {
  102. try {
  103. const { separate } = await import('./separate.mjs');
  104. const r = await separate(musicFile, { mode: 'both' });
  105. mark('音轨分离', 'ok', r.method === 'demucs' ? 'demucs(高质量)' : 'ffmpeg 相位兜底(近似)');
  106. log.ok(`方式:${r.method}`);
  107. } catch (e) {
  108. mark('音轨分离', 'failed', e.message.split('\n')[0]);
  109. log.err(`失败:${e.message.split('\n')[0]}`);
  110. }
  111. } else {
  112. mark('音轨分离', 'skipped', '无音乐文件');
  113. log.warn('无音乐文件,跳过');
  114. }
  115. // ---------- 4.5 人物资产(④,纯 AI 出片不换脸的关键) ----------
  116. log.step('[4.5/8] 人物资产多视图(即梦)');
  117. const sbLimited = { ...sb, shots: limit ? sb.shots.slice(0, limit) : sb.shots };
  118. let characterSheet = characterSheetFile || '';
  119. if (characterSheet && existsSync(characterSheet)) {
  120. mark('人物资产', 'provided', characterSheet);
  121. log.ok(`使用指定人物多视图:${characterSheet}`);
  122. } else if (skipCharacter) {
  123. mark('人物资产', 'skipped', '--skip-character');
  124. log.warn('按参数跳过(跨镜头可能换脸)');
  125. } else {
  126. const jp = getProvider('jimeng');
  127. const jr = await jp.probe({});
  128. if (!jr.available) {
  129. mark('人物资产', 'unavailable', jr.detail.split('\n')[0]);
  130. log.warn(`即梦通道不可用:${jr.detail}`);
  131. log.dim('替代:后续关键帧只靠分镜的 appearanceAnchor,跨镜头一致性会变差');
  132. } else {
  133. try {
  134. const { buildCharacterSheet } = await import('./character-sheet.mjs');
  135. const cs = await buildCharacterSheet({
  136. description: sb.appearanceAnchor || DEFAULT_ANCHOR.song,
  137. outDir: paths.out,
  138. limit: characterSteps,
  139. single: !!single,
  140. });
  141. const sel = cs.steps.find((s) => s.step === '4-多视图' && s.file) || cs.steps.filter((s) => s.file).pop();
  142. characterSheet = sel?.file || '';
  143. mark('人物资产', cs.cost.total > 0 ? 'ok' : 'reused', `${cs.steps.length} 步,¥${cs.cost.total.toFixed(2)}${characterSheet ? ` → ${characterSheet}` : ''}`);
  144. log.ok(`完成 ${cs.steps.length} 步,花费 ¥${cs.cost.total.toFixed(2)}`);
  145. } catch (e) {
  146. mark('人物资产', 'failed', e.message.split('\n')[0]);
  147. log.err(`失败:${e.message.split('\n')[0]}`);
  148. }
  149. }
  150. }
  151. // ---------- 5. 场景图 + 关键帧(⑤) ----------
  152. log.step('[5/8] 场景图(无人)→ 关键帧(有人)');
  153. let kf = { okCount: 0, failCount: 0 };
  154. const jp2 = getProvider('jimeng');
  155. const jr2 = await jp2.probe({});
  156. if (jr2.available) {
  157. const sc = await generateScenes({
  158. storyboard: sbLimited,
  159. characterSheet,
  160. sceneDir: join(paths.work, 'scene'),
  161. frameDir: paths.frames,
  162. tier,
  163. });
  164. kf = { okCount: sc.ok, failCount: sc.failed };
  165. mark('场景图+关键帧', sc.failed ? 'partial' : 'ok', `成功 ${sc.ok} / 失败 ${sc.failed} / 花费 ¥${sc.cost.total.toFixed(2)}`);
  166. log.ok(`成功 ${sc.ok},失败 ${sc.failed},花费 ¥${sc.cost.total.toFixed(2)}`);
  167. } else {
  168. // 即梦不可用 → 退回旧 image 通道出关键帧
  169. log.warn(`即梦通道不可用,退回旧 image 通道出关键帧:${jr2.detail}`);
  170. const r = await generateKeyframes({ storyboard: sbLimited, outDir: paths.frames, aspect });
  171. kf = { okCount: r.okCount, failCount: r.failCount };
  172. mark('场景图+关键帧', r.failCount ? 'partial' : 'ok', `(旧通道)成功 ${r.okCount} / 失败 ${r.failCount}`);
  173. log.ok(`(旧通道)成功 ${r.okCount} 张,失败 ${r.failCount} 张 → ${paths.frames}`);
  174. }
  175. // ---------- 6. 图生视频(⑥) ----------
  176. log.step('[6/8] 图生视频(即梦)');
  177. let clipResult = { results: [], mode: 'skipped' };
  178. if (skipVideo) {
  179. mark('图生视频', 'skipped', '--skip-video');
  180. log.warn('按参数跳过');
  181. } else {
  182. clipResult = await generateClips({ storyboard: sbLimited, mode: videoMode });
  183. const ok = clipResult.results.filter((x) => x.status === 'ok').length;
  184. const degraded = clipResult.results.filter((x) => x.degraded).length;
  185. mark(
  186. '图生视频',
  187. clipResult.mode === 'jimeng' ? 'ok' : 'fallback',
  188. `${clipResult.mode === 'jimeng' ? '即梦真通道' : 'Ken Burns 兜底'}:${ok} 段${degraded ? `(${degraded} 段首尾帧降级)` : ''},花费 ¥${clipResult.cost.total.toFixed(2)}`
  189. );
  190. log.ok(`${clipResult.mode === 'jimeng' ? '即梦真通道' : '兜底'}:${ok} 段,花费 ¥${clipResult.cost.total.toFixed(2)}`);
  191. }
  192. // ---------- 7. 唇形驱动 ----------
  193. log.step('[7/8] 唇形驱动');
  194. const lipShots = sbLimited.shots.filter((s) => s.lipSync);
  195. if (!lipShots.length) {
  196. mark('唇形驱动', 'n/a', '分镜中无 lipSync:true 镜头');
  197. log.dim('分镜里没有标记唇形的镜头,跳过');
  198. } else {
  199. const p = getProvider('lipsync');
  200. const pr = await p.probe({});
  201. if (!pr.available) {
  202. mark('唇形驱动', 'unavailable', `${lipShots.length} 个待处理镜头未处理`);
  203. log.warn(`通道不可用,${lipShots.length} 个唇形镜头未处理:${pr.detail}`);
  204. log.dim('替代:自建 Wav2Lip/SadTalker 并设 FEME_LIPSYNC_ENDPOINT;或改用表情微动镜头');
  205. } else {
  206. try {
  207. const { driveLip } = await import('./lipsync.mjs');
  208. let n = 0;
  209. for (const s of lipShots) {
  210. const face = join(paths.frames, `shot-${String(s.id).padStart(2, '0')}.png`);
  211. const audio = (musicFile && join(paths.audio, 'vocals.wav')) || join(paths.audio, 'vocals.wav');
  212. if (!existsSync(face) || !existsSync(audio)) continue;
  213. await driveLip(s, { faceImage: face, audio });
  214. n++;
  215. }
  216. mark('唇形驱动', 'ok', `${n} 个镜头`);
  217. log.ok(`${n} 个镜头完成`);
  218. } catch (e) {
  219. mark('唇形驱动', 'failed', e.message.split('\n')[0]);
  220. log.err(`失败:${e.message.split('\n')[0]}`);
  221. }
  222. }
  223. }
  224. // ---------- 8. 剪辑合成 ----------
  225. log.step('[8/8] 剪辑合成');
  226. let final = null;
  227. try {
  228. const r = await assemble({
  229. storyboard: sbLimited,
  230. clipsDir: paths.clips,
  231. outFile,
  232. music: musicFile || undefined,
  233. beatSync: true,
  234. });
  235. final = r.outFile;
  236. mark('剪辑合成', 'ok', `${r.info.duration.toFixed(1)}s / ${humanSize(r.info.size)}`);
  237. } catch (e) {
  238. mark('剪辑合成', 'failed', e.message.split('\n')[0]);
  239. log.err(`失败:${e.message.split('\n')[0]}`);
  240. }
  241. // ---------- 汇总 ----------
  242. const report = {
  243. pipeline: 'A',
  244. theme,
  245. title: song.title,
  246. aspect,
  247. shots: sb.shots.length,
  248. final,
  249. steps,
  250. generatedAt: new Date().toISOString(),
  251. };
  252. writeJSON(join(paths.work, 'pipeline-a.run.json'), report);
  253. printReport('产线A · AI 单曲 MV', report, { song, final });
  254. return report;
  255. }
  256. export function printReport(name, report, extra = {}) {
  257. console.log('');
  258. console.log('═'.repeat(64));
  259. console.log(` ${name} · 执行报告`);
  260. console.log('═'.repeat(64));
  261. const icon = { ok: '✅', reused: '♻️ ', provided: '📎', fallback: '◐ ', partial: '◐ ', skipped: '⏭️ ', unavailable: '⚠️ ', failed: '❌', 'n/a': '· ' };
  262. const w = Math.max(...report.steps.map((s) => s.name.length));
  263. for (const s of report.steps) {
  264. console.log(` ${icon[s.status] || ' '} ${s.name.padEnd(w)} ${s.detail || ''}`);
  265. }
  266. console.log('─'.repeat(64));
  267. if (report.final) {
  268. console.log(` 成片: ${report.final}`);
  269. } else {
  270. console.log(' ⚠️ 未产出成片(见上方失败/跳过项)');
  271. }
  272. console.log('');
  273. const bad = report.steps.filter((s) => s.status === 'failed' || s.status === 'unavailable');
  274. if (bad.length) {
  275. console.log(' 未完成项:');
  276. for (const b of bad) console.log(` · ${b.name}: ${b.detail}`);
  277. console.log(' 详见 SKILL.md「能力现状」与 references/capability-matrix.md');
  278. }
  279. console.log('═'.repeat(64));
  280. console.log('');
  281. }
  282. export async function main(argv = process.argv.slice(2)) {
  283. const { _, flags } = parseArgs(argv);
  284. const theme = _.join(' ').trim();
  285. if (flags.help || !theme) {
  286. console.log(`
  287. fmode-mv song — 产线A:AI 单曲 MV 端到端编排
  288. 用法:
  289. fmode-mv song "主题描述" [选项]
  290. 流程:
  291. 作曲填词 → 分镜 → AI音乐 → 音轨分离 → 关键帧 → 图生视频 → 唇形 → 剪辑成片
  292. 选项:
  293. --title "歌名" 指定歌名(否则模型自定)
  294. --genre "曲风" --mood "情绪"
  295. --shots <n> 镜头数(默认 8)
  296. --per-shot <s> 单镜秒数(默认 6)
  297. --limit <n> 只处理前 n 镜(试跑省钱)
  298. --aspect 16:9|9:16|1:1
  299. --music <file> 用自己已有的音乐(跳过 AI 音乐步骤)
  300. --skip-music 跳过音乐步骤
  301. --skip-video 跳过视频生成
  302. --out <file> 成片输出(默认 out/mv.mp4)
  303. --fresh 忽略已有 work/song.json,重新作曲填词
  304. 示例:
  305. fmode-mv song "深夜末班地铁上的城市白领" --genre "city pop" --limit 3
  306. fmode-mv song "..." --music my-song.mp3
  307. fmode-mv song "..." --limit 1 --char-steps 4 # 先跑 1 镜 + 完整人物资产(试水)
  308. ⚠️ 通道现状(fmode-mv providers 可查):
  309. ✅ chat / image / ffmpeg / 即梦(文生图·图生图·图生视频·数字人)可用;
  310. ❌ music / lipsync / tts / 旧 video 通道平台未开通。
  311. 未开通的步骤会如实跳过或降级兜底,并在报告中标出,绝不伪造产物。
  312. v0.2 新增选项:
  313. --character <file> 指定现成的人物多视图(跳过自动生成人物资产)
  314. --skip-character 跳过人物资产(省钱;跨镜头可能换脸)
  315. --char-steps <1-4> 人物资产跑到第几步(默认 4=多视图)
  316. --single 人物资产每步只出 1 张(跳过择优)
  317. --tier 1k|2k 场景/关键帧尺寸档(默认 1k)
  318. --video-mode auto|jimeng|still 视频通道(默认 auto;still=零 API 费用的 KB 兜底)
  319. `);
  320. return 0;
  321. }
  322. await runPipelineA(theme, {
  323. title: flags.title || '',
  324. genre: flags.genre || '',
  325. mood: flags.mood || '',
  326. limit: Number(flags.limit) || 0,
  327. aspect: flags.aspect || '16:9',
  328. shots: Number(flags.shots) || 8,
  329. perShot: Number(flags['per-shot']) || 6,
  330. outFile: String(flags.out || join(paths.out, 'mv.mp4')),
  331. music: typeof flags.music === 'string' ? flags.music : '',
  332. skipMusic: !!flags['skip-music'],
  333. skipVideo: !!flags['skip-video'],
  334. resume: !flags.fresh,
  335. characterSheetFile: typeof flags.character === 'string' ? flags.character : '',
  336. skipCharacter: !!flags['skip-character'],
  337. characterSteps: Number(flags['char-steps']) || 4,
  338. single: !!flags.single,
  339. tier: flags.tier || '1k',
  340. videoMode: flags['video-mode'] || 'auto',
  341. });
  342. return 0;
  343. }
  344. if (import.meta.url === `file://${process.argv[1]}`) {
  345. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  346. console.error(`✗ ${e.message}`);
  347. if (process.env.DEBUG) console.error(e.stack);
  348. process.exit(1);
  349. });
  350. }