| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- // 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 <file> 现成口播稿(md/txt),跳过自动撰写
- --duration <s> 目标时长(默认 90)
- --points "a,b,c" 必须覆盖的要点(逗号分隔)
- --audience "..." 受众描述,影响语气与用词
- --shots <n> 镜头数(默认 8)
- --limit <n> 只处理前 n 镜(试跑省钱)
- --aspect 16:9|9:16|1:1
- --voice <file> 用现成配音音频(跳过 TTS)
- --voice-name <n> TTS 音色名
- --music <file> 轻背景乐
- --skip-tts / --skip-video
- --out <file> 成片输出(默认 out/talk.mp4)
- ⚠️ 通道现状:TTS 平台未开通(本地装 edge-tts 即可用,或用 --voice 指定现成音频);
- ✅ 画面走即梦(人物资产 / 场景图 / 关键帧 / 图生视频)实测可用;
- ❌ 唇形驱动未开通 → 需要真人对口型时改用 fmode-mv jimeng digital-human。
- v0.2 新增选项:
- --character <file> 指定现成的人物多视图(跳过自动生成人物资产)
- --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);
- });
- }
|