// 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 歌曲 JSON(产线A) --script 口播稿文本文件(产线B) --shots 镜头数(默认 8) --per-shot 单镜头秒数,仅产线A(默认 6) --duration 口播总时长,仅产线B(默认 90) --aspect 16:9 | 9:16 | 1:1(默认 16:9) --project "..." 项目背景,帮助模型贴合语境 --model 覆盖 LLM 模型 --out 输出(默认 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); }); }