// scripts/pipeline-b.mjs — 产线B 编排:AI 口播讲解视频端到端 // // 口播稿 → TTS 配音 → 人物讲解画面 → 场景切换 B-roll → 剪辑合成成片 // // 与产线A 共用同一套底层模块(chat / image / video / tts / ffmpeg),只是编排不同。 import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, chatJSON, } from '../lib/index.mjs'; import { buildScriptPrompt, DEFAULT_ANCHOR } from '../lib/prompts.mjs'; import { buildStoryboardFromScript, renderStoryboardMd } from './storyboard.mjs'; import { generateKeyframes } from './keyframe.mjs'; import { generateScenes } from './scene.mjs'; import { generateClips } from './video.mjs'; import { assemble } from './assemble.mjs'; import { getProvider } from '../lib/providers.mjs'; import { printReport } from './pipeline-a.mjs'; const SYSTEM = `你是资深知识类短视频编导兼口播稿写手,稿件会被 AI 语音合成(TTS)直接朗读。 只输出 JSON,不要任何解释文字与 markdown 围栏。`; const SCHEMA = `严格输出如下 JSON(字段名不可改): { "title": "标题(≤20字,有吸引力)", "topic": "主题", "durationSec": 90, "hook": "开场 3 秒钩子原句", "cta": "结尾行动号召原句", "hostAnchor": "英文,讲者外貌锚点(性别/年龄/发型/服装/气质),逐镜头复述用", "sections": [{"name":"段落名","text":"该段口播原文"}], "fullText": "完整口播稿(各段拼接,用于 TTS 一次性合成)" }`; /** 生成口播稿 */ export async function buildOralScript(topic, opts = {}) { const { durationSec = 90, audience = '', tone = '专业但口语化', points = [], model } = opts; const user = buildScriptPrompt({ topic, durationSec, audience, tone, points }) + '\n\n' + SCHEMA; const res = await chatJSON({ model, system: SYSTEM, messages: [{ role: 'user', content: user }], temperature: 0.8, max_tokens: 4096, }); const parsed = res.json; if (!parsed.fullText) { parsed.fullText = (parsed.sections || []).map((s) => s.text).join('\n'); } parsed.hostAnchor = parsed.hostAnchor || DEFAULT_ANCHOR.talk; parsed._meta = { model: res.model, createdBy: 'fmode-mv/pipeline-b', topic }; return parsed; } export async function runPipelineB(topic, opts = {}) { const { scriptFile = '', duration = 90, aspect = '16:9', shots = 8, limit = 0, outFile = join(paths.out, 'talk.mp4'), voice = '', music = '', skipTts = false, skipVideo = false, audience = '', points = [], // v0.2 新增 characterSheetFile = '', skipCharacter = false, characterSteps = 4, single = false, tier = '1k', videoMode = 'auto', } = opts; ensureWork(); const steps = []; const mark = (name, status, detail) => steps.push({ name, status, detail }); // ---------- 1. 口播稿 ---------- log.step('[1/8] 口播稿'); let script; const scriptFileOut = join(paths.work, 'script.json'); if (scriptFile) { const { readFileSync } = await import('node:fs'); const text = readFileSync(scriptFile, 'utf8').trim(); script = { title: topic || '口播讲解', topic, fullText: text, sections: [{ name: '全文', text }], hostAnchor: DEFAULT_ANCHOR.talk }; mark('口播稿', 'provided', `读取 ${scriptFile}(${text.length} 字)`); log.ok(`读取 ${scriptFile}`); } else if (existsSync(scriptFileOut)) { script = readJSON(scriptFileOut); mark('口播稿', 'reused', `沿用 work/script.json《${script.title}》`); log.ok(`沿用已有《${script.title}》`); } else { script = await buildOralScript(topic, { durationSec: duration, audience, points }); mark('口播稿', 'ok', `${script.fullText.length} 字 / 约 ${Math.round(script.fullText.length / 3.6)}s`); log.ok(`《${script.title}》(${script.fullText.length} 字,约 ${Math.round(script.fullText.length / 3.6)} 秒)`); } writeJSON(scriptFileOut, script); writeFileSync(join(paths.work, 'script.md'), `# ${script.title}\n\n${script.fullText}\n`, 'utf8'); // ---------- 2. TTS 配音 ---------- log.step('[2/8] TTS 配音'); let voiceFile = voice && existsSync(voice) ? voice : null; if (voiceFile) { mark('TTS 配音', 'provided', voiceFile); log.ok(`使用指定配音:${voiceFile}`); } else if (skipTts) { mark('TTS 配音', 'skipped', '--skip-tts'); log.warn('按参数跳过'); } else { const p = getProvider('tts'); const pr = await p.probe({}); if (!pr.available) { mark('TTS 配音', 'unavailable', pr.detail.split('\n')[0]); log.warn(`通道不可用:${pr.detail}`); log.dim('替代:pip install edge-tts(免费本地)/ FEME_TTS_ENDPOINT 对接外部服务 / --voice 指定现成音频'); } else { try { const { synthesize } = await import('./tts.mjs'); const out = join(paths.audio, 'voiceover.mp3'); const r = await synthesize(script.fullText, { outPath: out, voice: opts.voiceName }); voiceFile = r.outPath; mark('TTS 配音', 'ok', `${r.provider} → ${voiceFile}`); log.ok(`${r.provider} → ${voiceFile}`); } catch (e) { mark('TTS 配音', 'failed', e.message.split('\n')[0]); log.err(`失败:${e.message.split('\n')[0]}`); } } } // ---------- 3. 分镜 ---------- log.step('[3/8] 讲解分镜'); const sb = await buildStoryboardFromScript(script.fullText, { durationSec: duration, aspect, shotCount: shots, hostAnchor: script.hostAnchor || '', }); sb.title = sb.title || script.title; writeJSON(paths.storyboard, sb); writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8'); mark('讲解分镜', 'ok', `${sb.shots.length} 镜 / ${sb.totalDuration}s`); log.ok(`${sb.shots.length} 个镜头,总时长 ${sb.totalDuration}s`); // ---------- 4. 人物资产(④) ---------- log.step('[4/8] 人物资产多视图(即梦)'); const jobs = limit ? sb.shots.slice(0, limit) : sb.shots; const sbLimited = { ...sb, shots: jobs }; let characterSheet = characterSheetFile || ''; if (characterSheet && existsSync(characterSheet)) { mark('人物资产', 'provided', characterSheet); log.ok(`使用指定人物多视图:${characterSheet}`); } else if (skipCharacter) { mark('人物资产', 'skipped', '--skip-character'); log.warn('按参数跳过(跨镜头可能换脸)'); } else { const jp = getProvider('jimeng'); const jr = await jp.probe({}); if (!jr.available) { mark('人物资产', 'unavailable', jr.detail.split('\n')[0]); log.warn(`即梦通道不可用:${jr.detail}`); } else { try { const { buildCharacterSheet } = await import('./character-sheet.mjs'); const cs = await buildCharacterSheet({ description: sb.appearanceAnchor || script.hostAnchor || DEFAULT_ANCHOR.talk, outDir: paths.out, limit: characterSteps, single: !!single, }); const sel = cs.steps.find((s) => s.step === '4-多视图' && s.file) || cs.steps.filter((s) => s.file).pop(); characterSheet = sel?.file || ''; mark('人物资产', cs.cost.total > 0 ? 'ok' : 'reused', `${cs.steps.length} 步,¥${cs.cost.total.toFixed(2)}`); log.ok(`完成 ${cs.steps.length} 步,花费 ¥${cs.cost.total.toFixed(2)}${characterSheet ? ` → ${characterSheet}` : ''}`); } catch (e) { mark('人物资产', 'failed', e.message.split('\n')[0]); log.err(`失败:${e.message.split('\n')[0]}`); } } } // ---------- 5. 场景图 + 关键帧(⑤) ---------- log.step('[5/8] 场景图(无人)→ 关键帧(有人)'); const jp2 = getProvider('jimeng'); const jr2 = await jp2.probe({}); if (jr2.available) { const sc = await generateScenes({ storyboard: sbLimited, characterSheet, sceneDir: join(paths.work, 'scene'), frameDir: paths.frames, tier, }); mark('场景图+关键帧', sc.failed ? 'partial' : 'ok', `成功 ${sc.ok} / 失败 ${sc.failed} / ¥${sc.cost.total.toFixed(2)}`); log.ok(`成功 ${sc.ok} 张 → ${paths.frames}(¥${sc.cost.total.toFixed(2)})`); } else { log.warn(`即梦通道不可用,退回旧 image 通道:${jr2.detail}`); const kf = await generateKeyframes({ storyboard: sbLimited, outDir: paths.frames, aspect }); mark('场景图+关键帧', kf.failCount ? 'partial' : 'ok', `(旧通道)成功 ${kf.okCount} / 失败 ${kf.failCount}`); log.ok(`(旧通道)成功 ${kf.okCount} 张 → ${paths.frames}`); } // ---------- 6. 场景 B-roll / 讲解画面(⑥) ---------- log.step('[6/8] 图生视频(即梦)'); let clipResult = { results: [], mode: 'skipped', cost: { total: 0 } }; if (skipVideo) { mark('视频片段', 'skipped', '--skip-video'); log.warn('按参数跳过'); } else { clipResult = await generateClips({ storyboard: sbLimited, mode: videoMode }); const ok = clipResult.results.filter((x) => x.status === 'ok').length; const degraded = clipResult.results.filter((x) => x.degraded).length; mark('视频片段', clipResult.mode === 'jimeng' ? 'ok' : 'fallback', `${clipResult.mode === 'jimeng' ? '即梦真通道' : 'Ken Burns 兜底'}:${ok} 段${degraded ? `(${degraded} 段降级)` : ''},¥${(clipResult.cost?.total || 0).toFixed(2)}`); log.ok(`${clipResult.mode === 'jimeng' ? '即梦真通道' : '兜底'}:${ok} 段,花费 ¥${(clipResult.cost?.total || 0).toFixed(2)}`); } // ---------- 6. 唇形(可选) ---------- const lipShots = sbLimited.shots.filter((s) => s.lipSync); if (lipShots.length) { log.step('[7/8] 讲者唇形驱动'); const p = getProvider('lipsync'); const pr = await p.probe({}); if (pr.available && voiceFile) { try { const { driveLip } = await import('./lipsync.mjs'); for (const s of lipShots) { const face = join(paths.frames, `shot-${String(s.id).padStart(2, '0')}.png`); if (existsSync(face)) await driveLip(s, { faceImage: face, audio: voiceFile }); } mark('唇形驱动', 'ok', `${lipShots.length} 个镜头`); } catch (e) { mark('唇形驱动', 'failed', e.message.split('\n')[0]); } } else { mark('唇形驱动', 'unavailable', `${lipShots.length} 个讲者镜头未对口型`); log.warn(`通道不可用,${lipShots.length} 个讲者镜头将不对口型(观感影响有限)`); } } // ---------- 7. 剪辑合成 ---------- log.step('[8/8] 剪辑合成'); let final = null; try { const r = await assemble({ storyboard: sbLimited, clipsDir: paths.clips, outFile, voice: voiceFile || undefined, bgm: music || undefined, bgmVolume: music ? 0.18 : 0, beatSync: false, }); final = r.outFile; mark('剪辑合成', 'ok', `${r.info.duration.toFixed(1)}s / ${humanSize(r.info.size)}`); } catch (e) { mark('剪辑合成', 'failed', e.message.split('\n')[0]); log.err(`失败:${e.message.split('\n')[0]}`); } const report = { pipeline: 'B', topic, title: script.title, aspect, shots: sb.shots.length, final, steps, generatedAt: new Date().toISOString(), }; writeJSON(join(paths.work, 'pipeline-b.run.json'), report); printReport('产线B · AI 口播讲解视频', report); return report; } export async function main(argv = process.argv.slice(2)) { const { _, flags } = parseArgs(argv); const topic = _.join(' ').trim(); if (flags.help || (!topic && !flags.script)) { console.log(` fmode-mv talk — 产线B:AI 口播讲解视频端到端编排 用法: fmode-mv talk "讲解主题" fmode-mv talk "主题" --points "要点1,要点2" --duration 90 fmode-mv talk --script my-script.md # 用现成口播稿 流程: 口播稿 → TTS配音 → 讲解分镜 → 关键帧 → B-roll/讲解画面 → 唇形(可选) → 剪辑成片 选项: --script 现成口播稿(md/txt),跳过自动撰写 --duration 目标时长(默认 90) --points "a,b,c" 必须覆盖的要点(逗号分隔) --audience "..." 受众描述,影响语气与用词 --shots 镜头数(默认 8) --limit 只处理前 n 镜(试跑省钱) --aspect 16:9|9:16|1:1 --voice 用现成配音音频(跳过 TTS) --voice-name TTS 音色名 --music 轻背景乐 --skip-tts / --skip-video --out 成片输出(默认 out/talk.mp4) ⚠️ 通道现状:TTS 平台未开通(本地装 edge-tts 即可用,或用 --voice 指定现成音频); ✅ 画面走即梦(人物资产 / 场景图 / 关键帧 / 图生视频)实测可用; ❌ 唇形驱动未开通 → 需要真人对口型时改用 fmode-mv jimeng digital-human。 v0.2 新增选项: --character 指定现成的人物多视图(跳过自动生成人物资产) --skip-character 跳过人物资产(省钱;跨镜头可能换脸) --char-steps <1-4> 人物资产跑到第几步(默认 4=多视图) --single 人物资产每步只出 1 张(跳过择优) --tier 1k|2k 场景/关键帧尺寸档(默认 1k) --video-mode auto|jimeng|still 视频通道(默认 auto) `); return 0; } await runPipelineB(topic || '口播讲解', { scriptFile: typeof flags.script === 'string' ? flags.script : '', duration: Number(flags.duration) || 90, aspect: flags.aspect || '16:9', shots: Number(flags.shots) || 8, limit: Number(flags.limit) || 0, outFile: String(flags.out || join(paths.out, 'talk.mp4')), voice: typeof flags.voice === 'string' ? flags.voice : '', music: typeof flags.music === 'string' ? flags.music : '', voiceName: typeof flags['voice-name'] === 'string' ? flags['voice-name'] : undefined, skipTts: !!flags['skip-tts'], skipVideo: !!flags['skip-video'], characterSheetFile: typeof flags.character === 'string' ? flags.character : '', skipCharacter: !!flags['skip-character'], characterSteps: Number(flags['char-steps']) || 4, single: !!flags.single, tier: flags.tier || '1k', videoMode: flags['video-mode'] || 'auto', audience: flags.audience || '', points: flags.points ? String(flags.points).split(',').map((s) => s.trim()).filter(Boolean) : [], }); return 0; } if (import.meta.url === `file://${process.argv[1]}`) { main().then((c) => process.exit(Number(c) || 0)).catch((e) => { console.error(`✗ ${e.message}`); if (process.env.DEBUG) console.error(e.stack); process.exit(1); }); }