pipeline-b.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. // scripts/pipeline-b.mjs — 产线B 编排:AI 口播讲解视频端到端
  2. //
  3. // 口播稿 → TTS 配音 → 人物讲解画面 → 场景切换 B-roll → 剪辑合成成片
  4. //
  5. // 与产线A 共用同一套底层模块(chat / image / video / tts / ffmpeg),只是编排不同。
  6. import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
  7. import { join } from 'node:path';
  8. import {
  9. parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, chatJSON,
  10. } from '../lib/index.mjs';
  11. import { buildScriptPrompt, DEFAULT_ANCHOR } from '../lib/prompts.mjs';
  12. import { buildStoryboardFromScript, renderStoryboardMd } from './storyboard.mjs';
  13. import { generateKeyframes } from './keyframe.mjs';
  14. import { generateClips } from './video.mjs';
  15. import { assemble } from './assemble.mjs';
  16. import { getProvider } from '../lib/providers.mjs';
  17. import { printReport } from './pipeline-a.mjs';
  18. const SYSTEM = `你是资深知识类短视频编导兼口播稿写手,稿件会被 AI 语音合成(TTS)直接朗读。
  19. 只输出 JSON,不要任何解释文字与 markdown 围栏。`;
  20. const SCHEMA = `严格输出如下 JSON(字段名不可改):
  21. {
  22. "title": "标题(≤20字,有吸引力)",
  23. "topic": "主题",
  24. "durationSec": 90,
  25. "hook": "开场 3 秒钩子原句",
  26. "cta": "结尾行动号召原句",
  27. "hostAnchor": "英文,讲者外貌锚点(性别/年龄/发型/服装/气质),逐镜头复述用",
  28. "sections": [{"name":"段落名","text":"该段口播原文"}],
  29. "fullText": "完整口播稿(各段拼接,用于 TTS 一次性合成)"
  30. }`;
  31. /** 生成口播稿 */
  32. export async function buildOralScript(topic, opts = {}) {
  33. const { durationSec = 90, audience = '', tone = '专业但口语化', points = [], model } = opts;
  34. const user =
  35. buildScriptPrompt({ topic, durationSec, audience, tone, points }) + '\n\n' + SCHEMA;
  36. const res = await chatJSON({
  37. model,
  38. system: SYSTEM,
  39. messages: [{ role: 'user', content: user }],
  40. temperature: 0.8,
  41. max_tokens: 4096,
  42. });
  43. const parsed = res.json;
  44. if (!parsed.fullText) {
  45. parsed.fullText = (parsed.sections || []).map((s) => s.text).join('\n');
  46. }
  47. parsed.hostAnchor = parsed.hostAnchor || DEFAULT_ANCHOR.talk;
  48. parsed._meta = { model: res.model, createdBy: 'fmode-mv/pipeline-b', topic };
  49. return parsed;
  50. }
  51. export async function runPipelineB(topic, opts = {}) {
  52. const {
  53. scriptFile = '',
  54. duration = 90,
  55. aspect = '16:9',
  56. shots = 8,
  57. limit = 0,
  58. outFile = join(paths.out, 'talk.mp4'),
  59. voice = '',
  60. music = '',
  61. skipTts = false,
  62. skipVideo = false,
  63. audience = '',
  64. points = [],
  65. } = opts;
  66. ensureWork();
  67. const steps = [];
  68. const mark = (name, status, detail) => steps.push({ name, status, detail });
  69. // ---------- 1. 口播稿 ----------
  70. log.step('[1/7] 口播稿');
  71. let script;
  72. const scriptFileOut = join(paths.work, 'script.json');
  73. if (scriptFile) {
  74. const { readFileSync } = await import('node:fs');
  75. const text = readFileSync(scriptFile, 'utf8').trim();
  76. script = { title: topic || '口播讲解', topic, fullText: text, sections: [{ name: '全文', text }], hostAnchor: DEFAULT_ANCHOR.talk };
  77. mark('口播稿', 'provided', `读取 ${scriptFile}(${text.length} 字)`);
  78. log.ok(`读取 ${scriptFile}`);
  79. } else if (existsSync(scriptFileOut)) {
  80. script = readJSON(scriptFileOut);
  81. mark('口播稿', 'reused', `沿用 work/script.json《${script.title}》`);
  82. log.ok(`沿用已有《${script.title}》`);
  83. } else {
  84. script = await buildOralScript(topic, { durationSec: duration, audience, points });
  85. mark('口播稿', 'ok', `${script.fullText.length} 字 / 约 ${Math.round(script.fullText.length / 3.6)}s`);
  86. log.ok(`《${script.title}》(${script.fullText.length} 字,约 ${Math.round(script.fullText.length / 3.6)} 秒)`);
  87. }
  88. writeJSON(scriptFileOut, script);
  89. writeFileSync(join(paths.work, 'script.md'), `# ${script.title}\n\n${script.fullText}\n`, 'utf8');
  90. // ---------- 2. TTS 配音 ----------
  91. log.step('[2/7] TTS 配音');
  92. let voiceFile = voice && existsSync(voice) ? voice : null;
  93. if (voiceFile) {
  94. mark('TTS 配音', 'provided', voiceFile);
  95. log.ok(`使用指定配音:${voiceFile}`);
  96. } else if (skipTts) {
  97. mark('TTS 配音', 'skipped', '--skip-tts');
  98. log.warn('按参数跳过');
  99. } else {
  100. const p = getProvider('tts');
  101. const pr = await p.probe({});
  102. if (!pr.available) {
  103. mark('TTS 配音', 'unavailable', pr.detail.split('\n')[0]);
  104. log.warn(`通道不可用:${pr.detail}`);
  105. log.dim('替代:pip install edge-tts(免费本地)/ FEME_TTS_ENDPOINT 对接外部服务 / --voice 指定现成音频');
  106. } else {
  107. try {
  108. const { synthesize } = await import('./tts.mjs');
  109. const out = join(paths.audio, 'voiceover.mp3');
  110. const r = await synthesize(script.fullText, { outPath: out, voice: opts.voiceName });
  111. voiceFile = r.outPath;
  112. mark('TTS 配音', 'ok', `${r.provider} → ${voiceFile}`);
  113. log.ok(`${r.provider} → ${voiceFile}`);
  114. } catch (e) {
  115. mark('TTS 配音', 'failed', e.message.split('\n')[0]);
  116. log.err(`失败:${e.message.split('\n')[0]}`);
  117. }
  118. }
  119. }
  120. // ---------- 3. 分镜 ----------
  121. log.step('[3/7] 讲解分镜');
  122. const sb = await buildStoryboardFromScript(script.fullText, {
  123. durationSec: duration,
  124. aspect,
  125. shotCount: shots,
  126. hostAnchor: script.hostAnchor || '',
  127. });
  128. sb.title = sb.title || script.title;
  129. writeJSON(paths.storyboard, sb);
  130. writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
  131. mark('讲解分镜', 'ok', `${sb.shots.length} 镜 / ${sb.totalDuration}s`);
  132. log.ok(`${sb.shots.length} 个镜头,总时长 ${sb.totalDuration}s`);
  133. // ---------- 4. 关键帧 ----------
  134. log.step('[4/7] 讲解画面关键帧');
  135. const jobs = limit ? sb.shots.slice(0, limit) : sb.shots;
  136. const sbLimited = { ...sb, shots: jobs };
  137. const kf = await generateKeyframes({ storyboard: sbLimited, outDir: paths.frames, aspect });
  138. mark('关键帧', kf.failCount ? 'partial' : 'ok', `成功 ${kf.okCount} / 失败 ${kf.failCount}`);
  139. log.ok(`成功 ${kf.okCount} 张 → ${paths.frames}`);
  140. // ---------- 5. 场景 B-roll / 讲解画面 ----------
  141. log.step('[5/7] 场景切换 B-roll / 讲解画面');
  142. let clipResult = { results: [], mode: 'skipped' };
  143. if (skipVideo) {
  144. mark('视频片段', 'skipped', '--skip-video');
  145. log.warn('按参数跳过');
  146. } else {
  147. clipResult = await generateClips({ storyboard: sbLimited, mode: 'auto' });
  148. const ok = clipResult.results.filter((x) => x.status === 'ok').length;
  149. mark('视频片段', clipResult.mode === 'provider' ? 'ok' : 'fallback', `${clipResult.mode === 'provider' ? '真通道' : 'Ken Burns 兜底'}:${ok} 段`);
  150. log.ok(`${clipResult.mode === 'provider' ? '真通道' : '兜底'}:${ok} 段`);
  151. }
  152. // ---------- 6. 唇形(可选) ----------
  153. const lipShots = sbLimited.shots.filter((s) => s.lipSync);
  154. if (lipShots.length) {
  155. log.step('[6/7] 讲者唇形驱动');
  156. const p = getProvider('lipsync');
  157. const pr = await p.probe({});
  158. if (pr.available && voiceFile) {
  159. try {
  160. const { driveLip } = await import('./lipsync.mjs');
  161. for (const s of lipShots) {
  162. const face = join(paths.frames, `shot-${String(s.id).padStart(2, '0')}.png`);
  163. if (existsSync(face)) await driveLip(s, { faceImage: face, audio: voiceFile });
  164. }
  165. mark('唇形驱动', 'ok', `${lipShots.length} 个镜头`);
  166. } catch (e) {
  167. mark('唇形驱动', 'failed', e.message.split('\n')[0]);
  168. }
  169. } else {
  170. mark('唇形驱动', 'unavailable', `${lipShots.length} 个讲者镜头未对口型`);
  171. log.warn(`通道不可用,${lipShots.length} 个讲者镜头将不对口型(观感影响有限)`);
  172. }
  173. }
  174. // ---------- 7. 剪辑合成 ----------
  175. log.step('[7/7] 剪辑合成');
  176. let final = null;
  177. try {
  178. const r = await assemble({
  179. storyboard: sbLimited,
  180. clipsDir: paths.clips,
  181. outFile,
  182. voice: voiceFile || undefined,
  183. bgm: music || undefined,
  184. bgmVolume: music ? 0.18 : 0,
  185. beatSync: false,
  186. });
  187. final = r.outFile;
  188. mark('剪辑合成', 'ok', `${r.info.duration.toFixed(1)}s / ${humanSize(r.info.size)}`);
  189. } catch (e) {
  190. mark('剪辑合成', 'failed', e.message.split('\n')[0]);
  191. log.err(`失败:${e.message.split('\n')[0]}`);
  192. }
  193. const report = {
  194. pipeline: 'B',
  195. topic,
  196. title: script.title,
  197. aspect,
  198. shots: sb.shots.length,
  199. final,
  200. steps,
  201. generatedAt: new Date().toISOString(),
  202. };
  203. writeJSON(join(paths.work, 'pipeline-b.run.json'), report);
  204. printReport('产线B · AI 口播讲解视频', report);
  205. return report;
  206. }
  207. export async function main(argv = process.argv.slice(2)) {
  208. const { _, flags } = parseArgs(argv);
  209. const topic = _.join(' ').trim();
  210. if (flags.help || (!topic && !flags.script)) {
  211. console.log(`
  212. fmode-mv talk — 产线B:AI 口播讲解视频端到端编排
  213. 用法:
  214. fmode-mv talk "讲解主题"
  215. fmode-mv talk "主题" --points "要点1,要点2" --duration 90
  216. fmode-mv talk --script my-script.md # 用现成口播稿
  217. 流程:
  218. 口播稿 → TTS配音 → 讲解分镜 → 关键帧 → B-roll/讲解画面 → 唇形(可选) → 剪辑成片
  219. 选项:
  220. --script <file> 现成口播稿(md/txt),跳过自动撰写
  221. --duration <s> 目标时长(默认 90)
  222. --points "a,b,c" 必须覆盖的要点(逗号分隔)
  223. --audience "..." 受众描述,影响语气与用词
  224. --shots <n> 镜头数(默认 8)
  225. --limit <n> 只处理前 n 镜(试跑省钱)
  226. --aspect 16:9|9:16|1:1
  227. --voice <file> 用现成配音音频(跳过 TTS)
  228. --voice-name <n> TTS 音色名
  229. --music <file> 轻背景乐
  230. --skip-tts / --skip-video
  231. --out <file> 成片输出(默认 out/talk.mp4)
  232. ⚠️ 通道现状:TTS 平台未开通(本地装 edge-tts 即可用);视频未开通会自动兜底为运镜静帧。
  233. `);
  234. return 0;
  235. }
  236. await runPipelineB(topic || '口播讲解', {
  237. scriptFile: typeof flags.script === 'string' ? flags.script : '',
  238. duration: Number(flags.duration) || 90,
  239. aspect: flags.aspect || '16:9',
  240. shots: Number(flags.shots) || 8,
  241. limit: Number(flags.limit) || 0,
  242. outFile: String(flags.out || join(paths.out, 'talk.mp4')),
  243. voice: typeof flags.voice === 'string' ? flags.voice : '',
  244. music: typeof flags.music === 'string' ? flags.music : '',
  245. voiceName: typeof flags['voice-name'] === 'string' ? flags['voice-name'] : undefined,
  246. skipTts: !!flags['skip-tts'],
  247. skipVideo: !!flags['skip-video'],
  248. audience: flags.audience || '',
  249. points: flags.points ? String(flags.points).split(',').map((s) => s.trim()).filter(Boolean) : [],
  250. });
  251. return 0;
  252. }
  253. if (import.meta.url === `file://${process.argv[1]}`) {
  254. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  255. console.error(`✗ ${e.message}`);
  256. if (process.env.DEBUG) console.error(e.stack);
  257. process.exit(1);
  258. });
  259. }