| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295 |
- // scripts/storyboard.mjs — 分镜脚本
- //
- // 输入:work/song.json(产线A)或口播稿(产线B)
- // 输出:work/storyboard.json,结构见 templates/storyboard.schema.json
- //
- // 分镜是整条链路的枢纽:后面的 keyframe / video / lipsync / assemble 全部读它。
- import {
- parseArgs, writeJSON, readJSON, paths, ensureWork, log, chatJSON,
- } from '../lib/index.mjs';
- import {
- buildStoryboardPrompt, buildTalkStoryboardPrompt, DEFAULT_ANCHOR,
- } from '../lib/prompts.mjs';
- const SYSTEM_A = `你是顶级音乐 MV 导演兼 AI 视频提示词工程师,服务于「全程 AI 生成、无实拍」的 MV 制作。
- 你的核心职责是:把一首歌拆成一个可被 AI 逐个执行的镜头清单,并保证跨镜头的人物/场景一致性。
- 只输出 JSON,不要任何解释文字与 markdown 围栏。`;
- const SYSTEM_B = `你是顶级口播讲解视频导演兼 AI 视频提示词工程师,服务于「全程 AI 生成、无实拍」的讲解视频。
- 你的核心职责是:把口播稿拆成镜头清单,让每一句口播都有对应的、语义相关的画面,并保持讲者形象一致。
- 只输出 JSON,不要任何解释文字与 markdown 围栏。`;
- const SCHEMA_A = `严格输出如下 JSON(字段名不可改):
- {
- "title": "与歌曲同名",
- "aspect": "16:9",
- "totalDuration": 180,
- "appearanceAnchor": "英文,主角外貌锚点(性别/年龄/发型/服装/气质),逐镜头复述用",
- "styleKeywords": "英文,全片统一影像风格关键词",
- "shots": [
- {
- "id": 1,
- "type": "broll | perf",
- "start": 0,
- "duration": 6,
- "section": "Intro",
- "description": "中文画面描述",
- "imagePrompt": "英文,完整可独立投喂文生图的提示词",
- "videoPrompt": "英文,图生视频的运镜/动作描述",
- "lipSync": false,
- "subtitle": "该镜头对应的歌词(用于字幕,可留空)",
- "notes": "可选,中文备注"
- }
- ]
- }`;
- const SCHEMA_B = `严格输出如下 JSON(字段名不可改):
- {
- "title": "标题",
- "aspect": "16:9",
- "totalDuration": 90,
- "appearanceAnchor": "英文,讲者外貌锚点",
- "styleKeywords": "英文,全片统一影像风格关键词",
- "shots": [
- {
- "id": 1,
- "type": "talking | broll",
- "start": 0,
- "duration": 8,
- "section": "开场钩子",
- "narration": "该镜头对应的口播原文",
- "subtitle": "字幕文本(≤20字)",
- "description": "中文画面描述",
- "imagePrompt": "英文,完整可独立投喂文生图的提示词",
- "videoPrompt": "英文,图生视频运镜描述",
- "lipSync": false,
- "notes": ""
- }
- ]
- }`;
- /** 归一化分镜,保证下游字段齐全 */
- export function normalizeStoryboard(sb, { aspect = '16:9', anchor = '', minShot = 3, maxShot = 10 } = {}) {
- const shots = (sb.shots || []).map((s, i) => {
- const dur = Number(s.duration) || 6;
- const start = s.start !== undefined && s.start !== null ? Number(s.start) : null;
- return {
- id: Number(s.id) || i + 1,
- type: s.type === 'perf' || s.type === 'talking' ? s.type : 'broll',
- start,
- duration: dur,
- section: s.section || '',
- description: s.description || s.prompt || '',
- imagePrompt: s.imagePrompt || s.description || '',
- videoPrompt: s.videoPrompt || 'subtle natural motion, slow cinematic push in',
- lipSync: !!s.lipSync && ['perf', 'talking'].includes(s.type === 'perf' ? 'perf' : s.type),
- subtitle: s.subtitle ?? s.narration ?? '',
- narration: s.narration ?? '',
- section: s.section || '',
- notes: s.notes || '',
- };
- });
- // 时长约束:AI 图生视频单段现实上限约 8~10 秒,模型常给出 30s+ 的不可能值。
- // 这里按规格(每段 5~8 秒)夹取,并如实记录被夹取的镜头,绝不偷偷改。
- let clamped = 0;
- for (const s of shots) {
- const before = s.duration;
- s.duration = Math.min(maxShot, Math.max(minShot, s.duration));
- if (Math.abs(s.duration - before) > 0.01) {
- s._durationClampedFrom = before;
- clamped++;
- }
- }
- // 补齐 start,保证时间轴连续
- let t = 0;
- for (const s of shots) {
- if (s.start === null || Number.isNaN(s.start)) s.start = +t.toFixed(3);
- t = s.start + s.duration;
- }
- const totalDuration = shots.length ? +(shots[shots.length - 1].start + shots[shots.length - 1].duration).toFixed(3) : 0;
- return {
- title: sb.title || '',
- aspect: sb.aspect || aspect,
- totalDuration,
- appearanceAnchor: sb.appearanceAnchor || anchor,
- styleKeywords: sb.styleKeywords || '',
- shots,
- _meta: {
- createdBy: 'fmode-mv/storyboard',
- generatedAt: new Date().toISOString(),
- durationClamped: clamped,
- },
- };
- }
- /** 产线A:从歌曲生成 MV 分镜 */
- export async function buildStoryboardFromSong(song, opts = {}) {
- const { shotCount = 8, aspect = '16:9', perShot = 6, model, project = '' } = opts;
- const prompt = buildStoryboardPrompt({ song, shotCount, aspect, perShot, project }) + '\n\n' + SCHEMA_A;
- const res = await chatJSON({
- model,
- system: SYSTEM_A,
- messages: [{ role: 'user', content: prompt }],
- temperature: 0.85,
- max_tokens: 12000,
- });
- const sb = normalizeStoryboard(res.json, { aspect });
- sb._meta = { ...sb._meta, model: res.model, maxTokensUsed: res.maxTokensUsed, repaired: !!res.repaired };
- return sb;
- }
- /** 产线B:从口播稿生成讲解分镜 */
- export async function buildStoryboardFromScript(script, opts = {}) {
- const { durationSec = 90, aspect = '16:9', shotCount = 8, model, project = '', hostAnchor = '' } = opts;
- const prompt =
- buildTalkStoryboardPrompt({ script, durationSec, aspect, shotCount, project, hostAnchor }) + '\n\n' + SCHEMA_B;
- const res = await chatJSON({
- model,
- system: SYSTEM_B,
- messages: [{ role: 'user', content: prompt }],
- temperature: 0.8,
- max_tokens: 12000,
- });
- const sb = normalizeStoryboard(res.json, { aspect, anchor: DEFAULT_ANCHOR.talk });
- sb._meta = { ...sb._meta, model: res.model, maxTokensUsed: res.maxTokensUsed, repaired: !!res.repaired };
- return sb;
- }
- export async function main(argv = process.argv.slice(2)) {
- const { _, flags } = parseArgs(argv);
- if (flags.help || (!_.length && !flags.from && !flags.script)) {
- console.log(`
- fmode-mv storyboard — 分镜脚本(chat 通道)
- 产线A(歌曲 MV):
- fmode-mv storyboard --from work/song.json [--shots 8] [--aspect 16:9] [--per-shot 6]
- 产线B(口播视频):
- fmode-mv storyboard --script work/script.md [--duration 90] [--shots 8]
- 选项:
- --from <file> 歌曲 JSON(产线A)
- --script <file> 口播稿文本文件(产线B)
- --shots <n> 镜头数(默认 8)
- --per-shot <s> 单镜头秒数,仅产线A(默认 6)
- --duration <s> 口播总时长,仅产线B(默认 90)
- --aspect <r> 16:9 | 9:16 | 1:1(默认 16:9)
- --project "..." 项目背景,帮助模型贴合语境
- --model <name> 覆盖 LLM 模型
- --out <file> 输出(默认 work/storyboard.json)
- --json 只打印 JSON
- `);
- return 0;
- }
- ensureWork();
- const aspect = flags.aspect || '16:9';
- let sb;
- if (flags.script) {
- const { readFileSync } = await import('node:fs');
- const script = readFileSync(String(flags.script), 'utf8');
- if (!script.trim()) throw new Error(`口播稿为空:${flags.script}`);
- log.step('生成口播分镜(产线B)');
- log.key('口播稿', `${script.length} 字`);
- sb = await buildStoryboardFromScript(script, {
- durationSec: Number(flags.duration) || 90,
- aspect,
- shotCount: Number(flags.shots) || 8,
- project: flags.project || '',
- model: flags.model,
- });
- } else {
- const from = typeof flags.from === 'string' ? flags.from : paths.song;
- const song = readJSON(from);
- if (!song) throw new Error(`未找到歌曲文件 ${from}。先跑:fmode-mv lyrics "主题"`);
- log.step(`生成 MV 分镜(产线A):《${song.title}》`);
- sb = await buildStoryboardFromSong(song, {
- shotCount: Number(flags.shots) || 8,
- aspect,
- perShot: Number(flags.per_shot) || 6,
- project: flags.project || '',
- model: flags.model,
- });
- }
- const outFile = typeof flags.out === 'string' ? flags.out : paths.storyboard;
- writeJSON(outFile, sb);
- const { writeFileSync } = await import('node:fs');
- const { join } = await import('node:path');
- writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
- if (flags.json) {
- console.log(JSON.stringify(sb, null, 2));
- return 0;
- }
- console.log(renderStoryboardMd(sb));
- log.ok(`已写入 ${outFile} 与 work/storyboard.md`);
- if (sb._meta?.durationClamped) {
- log.warn(
- `${sb._meta.durationClamped} 个镜头的时长超出合理区间(${3}~${10}s)已被夹取 —— ` +
- 'AI 图生视频单段现实上限约 8~10 秒,模型给出的几十秒镜头无法直接生成。'
- );
- }
- const perfs = sb.shots.filter((s) => s.lipSync).length;
- const brolls = sb.shots.filter((s) => !s.lipSync).length;
- log.key('镜头', `${sb.shots.length} 个(空镜/场景 ${brolls} + 唇形 ${perfs})`);
- log.key('总时长', `${sb.totalDuration}s`);
- if (!sb.appearanceAnchor) log.warn('缺少 appearanceAnchor(人物一致性锚点),跨镜头可能换脸');
- return 0;
- }
- export function renderStoryboardMd(sb) {
- const lines = [];
- lines.push(`# 分镜表 · ${sb.title}`);
- lines.push('');
- lines.push(`- 画面比例:${sb.aspect}`);
- lines.push(`- 总时长:${sb.totalDuration}s`);
- lines.push(`- 影像风格:${sb.styleKeywords || '-'}`);
- lines.push(`- 人物锚点:${sb.appearanceAnchor || '-'}`);
- lines.push('');
- lines.push('| # | 时间码 | 时长 | 类型 | 段落 | 画面 | 唇形 |');
- lines.push('|---|--------|------|------|------|------|------|');
- for (const s of sb.shots) {
- const tc = `${fmt(s.start)}–${fmt(s.start + s.duration)}`;
- lines.push(
- `| ${s.id} | ${tc} | ${s.duration}s | ${s.type} | ${s.section || '-'} | ${oneLine(s.description)} | ${s.lipSync ? '✓' : ''} |`
- );
- }
- lines.push('');
- lines.push('## 镜头明细');
- for (const s of sb.shots) {
- lines.push('');
- lines.push(`### #${s.id} · ${fmt(s.start)}–${fmt(s.start + s.duration)} · ${s.type}`);
- lines.push(`- 画面:${s.description}`);
- if (s.narration) lines.push(`- 口播:${s.narration}`);
- if (s.subtitle) lines.push(`- 字幕:${s.subtitle}`);
- lines.push(`- 图 prompt:\`${s.imagePrompt}\``);
- lines.push(`- 运镜 prompt:\`${s.videoPrompt}\``);
- }
- return lines.join('\n') + '\n';
- }
- function fmt(sec) {
- const s = Math.max(0, Number(sec) || 0);
- const m = Math.floor(s / 60);
- const r = (s % 60).toFixed(1).padStart(4, '0');
- return `${String(m).padStart(2, '0')}:${r}`;
- }
- function oneLine(s, n = 34) {
- const t = String(s || '').replace(/[\r\n|]/g, ' ').trim();
- return t.length > n ? t.slice(0, n) + '…' : t;
- }
- if (import.meta.url === `file://${process.argv[1]}`) {
- main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
- console.error(`✗ ${e.message}`);
- process.exit(1);
- });
- }
|