pipeline-a.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  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,
  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 { generateClips } from './video.mjs';
  18. import { assemble } from './assemble.mjs';
  19. import { getProvider } from '../lib/providers.mjs';
  20. import { probe as ffprobe, listMedia } from '../lib/ffmpeg.mjs';
  21. export async function runPipelineA(theme, opts = {}) {
  22. const {
  23. title = '',
  24. genre = '',
  25. mood = '',
  26. limit = 0,
  27. aspect = '16:9',
  28. shots = 8,
  29. perShot = 6,
  30. outFile = join(paths.out, 'mv.mp4'),
  31. music = '',
  32. skipMusic = false,
  33. skipVideo = false,
  34. dryRun = false,
  35. } = opts;
  36. ensureWork();
  37. const steps = [];
  38. const mark = (name, status, detail) => {
  39. steps.push({ name, status, detail });
  40. return status;
  41. };
  42. // ---------- 1. 作曲填词 ----------
  43. log.step('[1/8] 作曲填词');
  44. let song;
  45. if (existsSync(paths.song) && opts.resume !== false) {
  46. song = readJSON(paths.song);
  47. mark('作曲填词', 'reused', `沿用 work/song.json《${song.title}》`);
  48. log.ok(`沿用已有:《${song.title}》`);
  49. } else {
  50. song = await buildLyrics(theme, { genre, mood });
  51. writeJSON(paths.song, song);
  52. mark('作曲填词', 'ok', `《${song.title}》`);
  53. log.ok(`《${song.title}》(${song.genre || '-'} / ${song.bpm || '?'}BPM)`);
  54. }
  55. // ---------- 2. 分镜 ----------
  56. log.step('[2/8] 分镜脚本');
  57. const sb = await buildStoryboardFromSong(song, { shotCount: shots, aspect, perShot });
  58. writeJSON(paths.storyboard, sb);
  59. writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
  60. mark('分镜脚本', 'ok', `${sb.shots.length} 镜 / ${sb.totalDuration}s`);
  61. log.ok(`${sb.shots.length} 个镜头,总时长 ${sb.totalDuration}s`);
  62. // ---------- 3. AI 音乐 ----------
  63. log.step('[3/8] AI 音乐生成');
  64. let musicFile = music && existsSync(music) ? music : null;
  65. if (musicFile) {
  66. mark('AI 音乐', 'provided', `使用外部提供:${musicFile}`);
  67. log.ok(`使用指定音乐:${musicFile}`);
  68. } else if (skipMusic) {
  69. mark('AI 音乐', 'skipped', '--skip-music');
  70. log.warn('按参数跳过');
  71. } else {
  72. const p = getProvider('music');
  73. const pr = await p.probe({ deep: false });
  74. if (pr.available === false) {
  75. mark('AI 音乐', 'unavailable', pr.detail.split('\n')[0]);
  76. log.warn(`通道未开通,跳过:${pr.detail}`);
  77. log.dim('替代:--music <你的音频文件> / 等平台开通后重跑 / 自建兼容端点');
  78. } else {
  79. try {
  80. const { generateMusic } = await import('./music.mjs');
  81. const r = await generateMusic({ prompt: song.stylePrompt, lyrics: song.lyrics, title: song.title });
  82. musicFile = r.files[0].path;
  83. mark('AI 音乐', 'ok', musicFile);
  84. log.ok(musicFile);
  85. } catch (e) {
  86. mark('AI 音乐', 'failed', e.message.split('\n')[0]);
  87. log.err(`失败:${e.message.split('\n')[0]}`);
  88. }
  89. }
  90. }
  91. // ---------- 4. 音轨分离 ----------
  92. log.step('[4/8] 人声 / 伴奏分离');
  93. if (musicFile) {
  94. try {
  95. const { separate } = await import('./separate.mjs');
  96. const r = await separate(musicFile, { mode: 'both' });
  97. mark('音轨分离', 'ok', r.method === 'demucs' ? 'demucs(高质量)' : 'ffmpeg 相位兜底(近似)');
  98. log.ok(`方式:${r.method}`);
  99. } catch (e) {
  100. mark('音轨分离', 'failed', e.message.split('\n')[0]);
  101. log.err(`失败:${e.message.split('\n')[0]}`);
  102. }
  103. } else {
  104. mark('音轨分离', 'skipped', '无音乐文件');
  105. log.warn('无音乐文件,跳过');
  106. }
  107. // ---------- 5. 关键帧 ----------
  108. log.step('[5/8] 场景 & 人物关键帧');
  109. const kfJobs = limit ? sb.shots.slice(0, limit) : sb.shots;
  110. const sbLimited = { ...sb, shots: kfJobs };
  111. const kf = await generateKeyframes({ storyboard: sbLimited, outDir: paths.frames, aspect });
  112. mark('关键帧', kf.failCount ? 'partial' : 'ok', `成功 ${kf.okCount} / 失败 ${kf.failCount}`);
  113. log.ok(`成功 ${kf.okCount} 张,失败 ${kf.failCount} 张 → ${paths.frames}`);
  114. // ---------- 6. 图生视频 ----------
  115. log.step('[6/8] 图生视频');
  116. let clipResult = { results: [], mode: 'skipped' };
  117. if (skipVideo) {
  118. mark('图生视频', 'skipped', '--skip-video');
  119. log.warn('按参数跳过');
  120. } else {
  121. clipResult = await generateClips({ storyboard: sbLimited, mode: 'auto' });
  122. const ok = clipResult.results.filter((x) => x.status === 'ok').length;
  123. mark(
  124. '图生视频',
  125. clipResult.mode === 'provider' ? 'ok' : 'fallback',
  126. `${clipResult.mode === 'provider' ? '真通道' : 'Ken Burns 兜底'}:${ok} 段`
  127. );
  128. log.ok(`${clipResult.mode === 'provider' ? '真通道' : '兜底'}:${ok} 段`);
  129. }
  130. // ---------- 7. 唇形驱动 ----------
  131. log.step('[7/8] 唇形驱动');
  132. const lipShots = sbLimited.shots.filter((s) => s.lipSync);
  133. if (!lipShots.length) {
  134. mark('唇形驱动', 'n/a', '分镜中无 lipSync:true 镜头');
  135. log.dim('分镜里没有标记唇形的镜头,跳过');
  136. } else {
  137. const p = getProvider('lipsync');
  138. const pr = await p.probe({});
  139. if (!pr.available) {
  140. mark('唇形驱动', 'unavailable', `${lipShots.length} 个待处理镜头未处理`);
  141. log.warn(`通道不可用,${lipShots.length} 个唇形镜头未处理:${pr.detail}`);
  142. log.dim('替代:自建 Wav2Lip/SadTalker 并设 FEME_LIPSYNC_ENDPOINT;或改用表情微动镜头');
  143. } else {
  144. try {
  145. const { driveLip } = await import('./lipsync.mjs');
  146. let n = 0;
  147. for (const s of lipShots) {
  148. const face = join(paths.frames, `shot-${String(s.id).padStart(2, '0')}.png`);
  149. const audio = (musicFile && join(paths.audio, 'vocals.wav')) || join(paths.audio, 'vocals.wav');
  150. if (!existsSync(face) || !existsSync(audio)) continue;
  151. await driveLip(s, { faceImage: face, audio });
  152. n++;
  153. }
  154. mark('唇形驱动', 'ok', `${n} 个镜头`);
  155. log.ok(`${n} 个镜头完成`);
  156. } catch (e) {
  157. mark('唇形驱动', 'failed', e.message.split('\n')[0]);
  158. log.err(`失败:${e.message.split('\n')[0]}`);
  159. }
  160. }
  161. }
  162. // ---------- 8. 剪辑合成 ----------
  163. log.step('[8/8] 剪辑合成');
  164. let final = null;
  165. try {
  166. const r = await assemble({
  167. storyboard: sbLimited,
  168. clipsDir: paths.clips,
  169. outFile,
  170. music: musicFile || undefined,
  171. beatSync: true,
  172. });
  173. final = r.outFile;
  174. mark('剪辑合成', 'ok', `${r.info.duration.toFixed(1)}s / ${humanSize(r.info.size)}`);
  175. } catch (e) {
  176. mark('剪辑合成', 'failed', e.message.split('\n')[0]);
  177. log.err(`失败:${e.message.split('\n')[0]}`);
  178. }
  179. // ---------- 汇总 ----------
  180. const report = {
  181. pipeline: 'A',
  182. theme,
  183. title: song.title,
  184. aspect,
  185. shots: sb.shots.length,
  186. final,
  187. steps,
  188. generatedAt: new Date().toISOString(),
  189. };
  190. writeJSON(join(paths.work, 'pipeline-a.run.json'), report);
  191. printReport('产线A · AI 单曲 MV', report, { song, final });
  192. return report;
  193. }
  194. export function printReport(name, report, extra = {}) {
  195. console.log('');
  196. console.log('═'.repeat(64));
  197. console.log(` ${name} · 执行报告`);
  198. console.log('═'.repeat(64));
  199. const icon = { ok: '✅', reused: '♻️ ', provided: '📎', fallback: '◐ ', partial: '◐ ', skipped: '⏭️ ', unavailable: '⚠️ ', failed: '❌', 'n/a': '· ' };
  200. const w = Math.max(...report.steps.map((s) => s.name.length));
  201. for (const s of report.steps) {
  202. console.log(` ${icon[s.status] || ' '} ${s.name.padEnd(w)} ${s.detail || ''}`);
  203. }
  204. console.log('─'.repeat(64));
  205. if (report.final) {
  206. console.log(` 成片: ${report.final}`);
  207. } else {
  208. console.log(' ⚠️ 未产出成片(见上方失败/跳过项)');
  209. }
  210. console.log('');
  211. const bad = report.steps.filter((s) => s.status === 'failed' || s.status === 'unavailable');
  212. if (bad.length) {
  213. console.log(' 未完成项:');
  214. for (const b of bad) console.log(` · ${b.name}: ${b.detail}`);
  215. console.log(' 详见 SKILL.md「能力现状」与 references/capability-matrix.md');
  216. }
  217. console.log('═'.repeat(64));
  218. console.log('');
  219. }
  220. export async function main(argv = process.argv.slice(2)) {
  221. const { _, flags } = parseArgs(argv);
  222. const theme = _.join(' ').trim();
  223. if (flags.help || !theme) {
  224. console.log(`
  225. fmode-mv song — 产线A:AI 单曲 MV 端到端编排
  226. 用法:
  227. fmode-mv song "主题描述" [选项]
  228. 流程:
  229. 作曲填词 → 分镜 → AI音乐 → 音轨分离 → 关键帧 → 图生视频 → 唇形 → 剪辑成片
  230. 选项:
  231. --title "歌名" 指定歌名(否则模型自定)
  232. --genre "曲风" --mood "情绪"
  233. --shots <n> 镜头数(默认 8)
  234. --per-shot <s> 单镜秒数(默认 6)
  235. --limit <n> 只处理前 n 镜(试跑省钱)
  236. --aspect 16:9|9:16|1:1
  237. --music <file> 用自己已有的音乐(跳过 AI 音乐步骤)
  238. --skip-music 跳过音乐步骤
  239. --skip-video 跳过视频生成
  240. --out <file> 成片输出(默认 out/mv.mp4)
  241. --fresh 忽略已有 work/song.json,重新作曲填词
  242. 示例:
  243. fmode-mv song "深夜末班地铁上的城市白领" --genre "city pop" --limit 3
  244. fmode-mv song "..." --music my-song.mp3
  245. ⚠️ 通道现状(fmode-mv providers 可查):
  246. ✅ chat / image / ffmpeg 可用;⚠️ music / video / lipsync 平台未开通。
  247. 未开通的步骤会如实跳过或降级兜底,并在报告中标出,绝不伪造产物。
  248. `);
  249. return 0;
  250. }
  251. await runPipelineA(theme, {
  252. title: flags.title || '',
  253. genre: flags.genre || '',
  254. mood: flags.mood || '',
  255. limit: Number(flags.limit) || 0,
  256. aspect: flags.aspect || '16:9',
  257. shots: Number(flags.shots) || 8,
  258. perShot: Number(flags['per-shot']) || 6,
  259. outFile: String(flags.out || join(paths.out, 'mv.mp4')),
  260. music: typeof flags.music === 'string' ? flags.music : '',
  261. skipMusic: !!flags['skip-music'],
  262. skipVideo: !!flags['skip-video'],
  263. resume: !flags.fresh,
  264. });
  265. return 0;
  266. }
  267. if (import.meta.url === `file://${process.argv[1]}`) {
  268. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  269. console.error(`✗ ${e.message}`);
  270. if (process.env.DEBUG) console.error(e.stack);
  271. process.exit(1);
  272. });
  273. }