// scripts/scene.mjs — 工序⑤:场景图(从关键帧拆分) // // 为什么要拆出「场景图」:AI 图生视频最稳的首帧是「先干净环境、再放人」。 // 直接文生图出「环境+人物」的图,模型要在一次推理里同时解决空间、光线、人脸三件事, // 结果往往是背景每镜都不一样、人物和背景光照打架。拆成两步就稳得多: // // 场景图(无人/纯环境,文生图) // ↓ 以场景图为参考 + 人物资产多视图 // 关键帧(有人物,图生图多图参考) // // 相邻镜头通过 prevScene 注入一致性约束(同一空间结构/光源方向/色温)。 import { existsSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, fileBytes, CostLedger, buildScenePrompt, buildSceneKeyframePrompt, DEFAULT_ANCHOR, resolveSize, } from '../lib/index.mjs'; import { getProvider } from '../lib/providers.mjs'; /** 把返回里的费用明细渲染成台账样式(避免重新 new 一个空台账) */ function ledgerSummary(cost) { const lines = ['费用台账 · 场景图 / 关键帧']; for (const e of cost.entries || []) { lines.push(` ¥${Number(e.amount).toFixed(2)} [${e.actual != null ? '实扣' : '预估'}] ${e.kind}${e.detail ? ` · ${e.detail}` : ''}`); } lines.push(` 合计 ¥${Number(cost.total).toFixed(2)}`); return lines.join('\n'); } /** * 场景图 / 关键帧尺寸。 * * 两条不同约束(实测踩坑): * · 场景图走 text2imgV3 → 面积 ∈ [512², 2048²],1K 档可用 1664×936 * · 关键帧走 getImgV4 → 面积必须 ≥ 1024²,所以最低就是 2560×1440(没有 1K 档) * 直接传小尺寸不会在提交时报错,要等轮询才回 'height or width invalid',所以用 resolveSize 取推荐值。 */ export function aspectToJimengSize(aspect = '16:9', tier = '2k', endpoint = 'text2imgV3') { return resolveSize(endpoint, aspect, endpoint === 'imgV4' ? '2k' : tier); } /** * 为分镜逐镜头生成「场景图」+「关键帧」。 * * @param {object} o * @param {object} o.storyboard * @param {string} [o.characterSheet] 人物多视图路径(第④步产物)——有它才不换脸 * @param {string} [o.sceneDir] 场景图输出目录(默认 work/scene) * @param {string} [o.frameDir] 关键帧输出目录(默认 work/frames) * @param {number} [o.shotId] 只处理某一镜 * @param {number} [o.limit] 只处理前 n 镜 * @param {'1k'|'2k'} [o.tier] * @param {boolean} [o.sceneOnly] 只出场景图,不出关键帧 * @param {boolean} [o.frameOnly] 只出关键帧(沿用已有场景图) */ export async function generateScenes(o = {}) { const { storyboard, characterSheet = '', sceneDir = join(resolve(paths.work), 'scene'), frameDir = paths.frames, shotId, limit, tier = '1k', sceneOnly = false, frameOnly = false, force = false, style = '', appearanceAnchor = '', } = o; if (!storyboard) throw new Error('generateScenes 需要 storyboard'); mkdirSync(sceneDir, { recursive: true }); mkdirSync(frameDir, { recursive: true }); const aspect = storyboard.aspect || '16:9'; const sceneSize = aspectToJimengSize(aspect, tier, 'text2imgV3'); const frameSize = aspectToJimengSize(aspect, tier, 'imgV4'); const anchor = appearanceAnchor || storyboard.appearanceAnchor || DEFAULT_ANCHOR.song; const styleKw = style || storyboard.styleKeywords || ''; const provider = getProvider('jimeng'); const ledger = new CostLedger('场景图 / 关键帧'); let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId)); if (limit) shots = shots.slice(0, Number(limit)); const hasChar = !!characterSheet && existsSync(characterSheet); if (!hasChar && !sceneOnly) { log.warn(`未提供人物资产多视图(--character),关键帧将不含身份锁定 —— 跨镜头大概率换脸`); log.dim('建议先跑:fmode-mv character-sheet --desc "..." --limit 4'); } const results = []; let prevSceneDesc = ''; for (const shot of shots) { const name = `shot-${String(shot.id).padStart(2, '0')}`; const sceneFile = join(sceneDir, `${name}.png`); const frameFile = join(frameDir, `${name}.png`); // 相邻镜头一致性约束:把上一镜的场景描述复述进本镜 prompt const continuityDesc = prevSceneDesc || shot.continuity || ''; // ---------- 场景图(无人) ---------- let sceneOk = false; if (!frameOnly) { if (existsSync(sceneFile) && !force) { log.dim(`[${name}] 场景图已存在,跳过`); sceneOk = true; } else { const p = buildScenePrompt(shot, { style: styleKw, prevScene: continuityDesc, aspect }); log.info(`[${name}] 场景图(无人)${sceneSize.width}×${sceneSize.height}`); try { await provider.text2img({ prompt: p, width: sceneSize.width, height: sceneSize.height, outFile: sceneFile, ledger, }); sceneOk = true; log.ok(`→ ${sceneFile}(${humanSize(fileBytes(sceneFile))})`); results.push({ id: shot.id, kind: 'scene', file: sceneFile, status: 'ok', bytes: fileBytes(sceneFile), prompt: p }); } catch (e) { log.err(`[${name}] 场景图失败:${String(e.message).split('\n')[0]}`); results.push({ id: shot.id, kind: 'scene', file: null, status: 'failed', error: e.message, prompt: p }); } } } else { sceneOk = existsSync(sceneFile); } // 记录本镜场景描述,供下一镜做连续性约束 prevSceneDesc = shot.scenePrompt || shot.description || ''; if (sceneOnly) continue; // ---------- 关键帧(有人物,图生图多图参考) ---------- if (existsSync(frameFile) && !force) { log.dim(`[${name}] 关键帧已存在,跳过`); results.push({ id: shot.id, kind: 'frame', file: frameFile, status: 'skipped', bytes: fileBytes(frameFile) }); continue; } const refs = [sceneFile, hasChar ? characterSheet : null].filter((p) => p && existsSync(p)); if (!refs.length) { log.err(`[${name}] 没有可用参考图(场景图缺失且未提供人物资产),跳过`); results.push({ id: shot.id, kind: 'frame', file: null, status: 'failed', error: '无参考图' }); continue; } const p = buildSceneKeyframePrompt(shot, { appearanceAnchor: anchor, style: styleKw, aspect }); log.info(`[${name}] 关键帧(参考 ${refs.length} 图:${sceneOk ? '场景图' : ''}${hasChar ? '+人物多视图' : ''})${frameSize.width}×${frameSize.height}`); try { await provider.img2img({ prompt: p, refImagePaths: refs, width: frameSize.width, height: frameSize.height, scale: 0.5, outFile: frameFile, ledger, }); log.ok(`→ ${frameFile}(${humanSize(fileBytes(frameFile))})`); results.push({ id: shot.id, kind: 'frame', file: frameFile, status: 'ok', bytes: fileBytes(frameFile), refs, prompt: p }); } catch (e) { log.err(`[${name}] 关键帧失败:${String(e.message).split('\n')[0]}`); results.push({ id: shot.id, kind: 'frame', file: null, status: 'failed', error: e.message, refs, prompt: p }); } } const out = { aspect, sceneSize, frameSize, tier, characterSheet: hasChar ? characterSheet : null, appearanceAnchor: anchor, styleKeywords: styleKw, results, ok: results.filter((r) => r.status === 'ok').length, failed: results.filter((r) => r.status === 'failed').length, skipped: results.filter((r) => r.status === 'skipped').length, cost: { total: ledger.total, entries: ledger.entries }, _meta: { createdBy: 'fmode-mv/scene', generatedAt: new Date().toISOString() }, }; writeJSON(join(sceneDir, 'scenes.json'), out); return out; } export async function main(argv = process.argv.slice(2)) { const { flags } = parseArgs(argv); if (flags.help || !flags.from) { console.log(` fmode-mv scene — 场景图(工序⑤,从关键帧拆分) 做法: ① 场景图(无人/纯环境) —— 文生图,为每个镜头铺环境 ② 关键帧(有人物) —— 图生图多图参考:场景图 + 人物资产多视图 相邻镜头通过「上一镜场景描述」注入一致性约束(同光源方向/同色温/同镜头感) 用法: fmode-mv scene --from work/storyboard.json --character out/character/4-多视图.png fmode-mv scene --from work/storyboard.json --shot 1 --limit 2 # 试跑 fmode-mv scene --from work/storyboard.json --scene-only # 只出场景图 fmode-mv scene --from work/storyboard.json --frame-only # 只出关键帧(沿用场景图) 选项: --from 分镜 JSON(templates/storyboard.schema.json 结构) --character 人物资产多视图(第④步产物)★强烈建议传,否则会换脸 --shot 只处理某一镜 --limit 只处理前 n 镜(试跑省钱) --tier 1k|2k 尺寸档位(默认 1k,省钱;2k 更清晰但更贵) --scene-dir 场景图目录(默认 work/scene) --frame-dir 关键帧目录(默认 work/frames) --anchor "..." 覆盖人物外观锚点(英文) --style "..." 追加风格关键词 --force 覆盖已存在产物 --scene-only / --frame-only 产出: work/scene/shot-XX.png(无人环境) work/frames/shot-XX.png(有人物关键帧,供 video 工序消费) work/scene/scenes.json(含 prompt / 费用台账) 费用: 每镜 2 次即梦调用(场景 ¥0.20 + 关键帧 ¥0.22)≈ ¥0.42/镜 `); return 0; } const from = String(flags.from); if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`); const storyboard = readJSON(from); ensureWork(); log.step(`生成场景图 + 关键帧:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`); const r = await generateScenes({ storyboard, characterSheet: typeof flags.character === 'string' ? flags.character : '', sceneDir: String(flags['scene-dir'] || join(paths.work, 'scene')), frameDir: String(flags['frame-dir'] || paths.frames), shotId: flags.shot ? Number(flags.shot) : undefined, limit: flags.limit ? Number(flags.limit) : undefined, tier: flags.tier || '1k', sceneOnly: !!flags['scene-only'], frameOnly: !!flags['frame-only'], force: !!flags.force, style: typeof flags.style === 'string' ? flags.style : '', appearanceAnchor: typeof flags.anchor === 'string' ? flags.anchor : '', }); log.raw(''); log.raw(r.cost.total ? ledgerSummary(r.cost) : ' 费用台账 · 无新调用(全部跳过)'); log.key('结果', `成功 ${r.ok} / 跳过 ${r.skipped} / 失败 ${r.failed}`); log.key('场景图', join(paths.work, 'scene')); log.key('关键帧', paths.frames); return r.failed > 0 && r.ok === 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}`); if (process.env.DEBUG) console.error(e.stack); process.exit(1); }); }