| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- // 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 <storyboard.json>');
- 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 <file> 分镜 JSON
- --shot <id> 只生成指定镜头
- --limit <n> 最多生成 n 镜
- --prompt "..." 裸 prompt(不依赖分镜)
- --out <dir> 输出目录(默认 work/frames)
- --aspect <r> 16:9 | 9:16 | 1:1(默认取分镜)
- --tier standard|large 尺寸档位(默认 standard)
- --style "..." 追加风格关键词(英文更稳)
- --anchor "..." 覆盖人物外观锚点(英文)
- --model <name> 覆盖图像模型(默认 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);
- });
- }
|