pipeline-b.mjs 15 KB

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