// scripts/keyframe.mjs — 场景 & 人物关键帧(provider: image) // // ✅ 通道可用(实测 gpt-image-2.5-sunburst 返回 b64_json)。 // 关键点:人物一致性 —— 每个含人物的镜头都必须复述 appearanceAnchor。 import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, humanSize, } from '../lib/index.mjs'; import { buildKeyframePrompt, aspectToSize, DEFAULT_ANCHOR } from '../lib/prompts.mjs'; /** * 生成关键帧。 * @param {object} o * @param {object} o.storyboard 分镜(可选) * @param {string} [o.prompt] 裸 prompt(无分镜时) * @param {number} [o.shotId] 只生成某一镜 * @param {number} [o.limit] 最多生成几镜 * @param {string} [o.outDir] */ export async function generateKeyframes(o = {}) { const { storyboard, prompt, shotId, limit, outDir = paths.frames, aspect = storyboard?.aspect || '16:9', tier = 'standard', style = '', model, anchorOverride, dryRun = false, } = o; mkdirSync(outDir, { recursive: true }); const anchor = anchorOverride || storyboard?.appearanceAnchor || DEFAULT_ANCHOR.song; const styleKw = style || storyboard?.styleKeywords || ''; const size = aspectToSize(aspect, tier); let jobs; if (storyboard) { jobs = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId)); if (limit) jobs = jobs.slice(0, Number(limit)); } else { if (!prompt) throw new Error('需要 --prompt 或 --from '); jobs = [{ id: 0, imagePrompt: prompt, description: prompt, type: 'broll', hasCharacter: false }]; } const results = []; for (const shot of jobs) { const fullPrompt = buildKeyframePrompt(shot, { appearanceAnchor: anchor, style: styleKw, aspect }); const name = shot.id ? `shot-${String(shot.id).padStart(2, '0')}` : 'keyframe'; const file = join(outDir, `${name}.png`); log.info(`[${name}] ${String(shot.description || '').slice(0, 60)}`); if (dryRun) { log.dim(`prompt: ${fullPrompt.slice(0, 160)}…`); results.push({ id: shot.id, file: null, status: 'pending', prompt: fullPrompt, size }); continue; } try { const res = await invoke('image', { prompt: fullPrompt, size, n: 1, model }); const buf = res.buffers[0]; writeFileSync(file, buf); log.ok(`→ ${file}(${humanSize(buf.length)},${size})`); results.push({ id: shot.id, file, bytes: buf.length, size, model: res.model, prompt: fullPrompt, status: 'ok', }); } catch (e) { log.err(`[${name}] 生成失败:${e.message.split('\n')[0]}`); results.push({ id: shot.id, file: null, status: 'failed', error: e.message, prompt: fullPrompt }); if (jobs.length === 1) throw e; } } const okCount = results.filter((r) => r.status === 'ok').length; const failCount = results.filter((r) => r.status === 'failed').length; writeJSON(join(outDir, 'keyframes.json'), { count: results.length, ok: okCount, failed: failCount, pending: results.filter((r) => r.status === 'pending').length, aspect, size, appearanceAnchor: anchor, styleKeywords: styleKw, frames: results, _meta: { createdBy: 'fmode-mv/keyframe', generatedAt: new Date().toISOString() }, }); return { results, okCount, failCount, outDir }; } export async function main(argv = process.argv.slice(2)) { const { _, flags } = parseArgs(argv); if (flags.help || (!flags.from && !flags.prompt)) { console.log(` fmode-mv keyframe — 场景 & 人物关键帧(image 通道,✅ 可用) 用法: fmode-mv keyframe --from work/storyboard.json # 按分镜批量生成 fmode-mv keyframe --from work/storyboard.json --shot 1 # 只生成第 1 镜 fmode-mv keyframe --from work/storyboard.json --limit 4 # 只生成前 4 镜 fmode-mv keyframe --prompt "傍晚城市天台,主角侧影,霓虹初上" --name rooftop 选项: --from 分镜 JSON --shot 只生成指定镜头 --limit 最多生成 n 镜 --prompt "..." 裸 prompt(不依赖分镜) --out 输出目录(默认 work/frames) --aspect 16:9 | 9:16 | 1:1(默认取分镜) --tier standard|large 尺寸档位(默认 standard) --style "..." 追加风格关键词(英文更稳) --anchor "..." 覆盖人物外观锚点(英文) --model 覆盖图像模型(默认 gpt-image-2.5-sunburst) --dry-run 只打印将发送的 prompt,不实际生成(不产生费用) 产出: work/frames/shot-XX.png + work/frames/keyframes.json `); return 0; } ensureWork(); let storyboard = null; if (flags.from) { const from = String(flags.from); if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`); storyboard = readJSON(from); log.step(`生成关键帧:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`); } else { log.step('生成关键帧(单张)'); } const r = await generateKeyframes({ storyboard, prompt: flags.prompt ? String(flags.prompt) : undefined, shotId: flags.shot ? Number(flags.shot) : undefined, limit: flags.limit ? Number(flags.limit) : undefined, outDir: typeof flags.out === 'string' ? flags.out : paths.frames, aspect: flags.aspect, tier: flags.tier || 'standard', style: flags.style, model: flags.model, anchorOverride: flags.anchor, dryRun: !!flags['dry-run'], }); if (flags['dry-run']) { log.warn(`dry-run:${r.results.length} 个 prompt 已生成,未调用图像通道(无费用)`); } else { log.ok(`完成:成功 ${r.okCount} / 失败 ${r.failCount}`); const files = readdirSync(paths.frames).filter((f) => f.endsWith('.png')); let total = 0; for (const f of files) total += statSync(join(paths.frames, f)).size; log.key('目录', `${paths.frames}(${files.length} 张,共 ${humanSize(total)})`); } return r.failCount > 0 && r.okCount === 0 ? 1 : 0; } 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); }); }