| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- // scripts/video.mjs — 工序⑥:图生视频(即梦视频 3.0)
- //
- // ★ 本工序已从「旧网关通道(503 无渠道)+ Ken Burns 兜底」升级为「即梦真实通道」。
- // 实测(2026-10-13):/getVideoV3_720p,frames=121(5s),返回 960×960 h264 mp4 1.09MB。
- //
- // 三条硬规则(来自 KB 的实战踩坑):
- // 1. frames = 24 × 秒数 + 1,且接口只接受 [121, 241] → 可用时长就是 5~10 秒
- // 2. 第一镜优先 method=3(首尾帧);失败必须自动降级 method=2(首帧)
- // 3. 每镜 prompt 必须含「音效描述 + 运镜 + 4k 画质词」
- //
- // 兜底仍在(--mode still):通道整体不可用时用「静帧 + Ken Burns 运镜」先出片,
- // 但产物一律带 .fallback 标记,绝不冒充真 AI 图生视频。
- import { existsSync, mkdirSync, appendFileSync, statSync } from 'node:fs';
- import { join } from 'node:path';
- import {
- parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, fileBytes,
- CostLedger, buildVideoPrompt, aspectToVideoSize, secondsToFrames, framesToSeconds,
- JIMENG, VIDEO_FRAMES, ENDPOINTS, getProvider,
- } from '../lib/index.mjs';
- import { kenBurns, checkFfmpeg } from '../lib/ffmpeg.mjs';
- /** workId 日志(KB 要求:`[时间戳] 类型 | workId: xxx | 内容: xxx`) */
- function appendWorkIdLog(logFile, { type, workId, content }) {
- try {
- mkdirSync(join(logFile, '..'), { recursive: true });
- appendFileSync(logFile, `[${new Date().toISOString()}] ${type} | workId: ${workId} | 内容: ${content}\n`, 'utf8');
- } catch {
- /* 日志失败不影响主流程 */
- }
- }
- /**
- * 逐镜生成视频片段。
- *
- * @param {object} o
- * @param {object} o.storyboard
- * @param {string} [o.keyframesDir] 关键帧目录(默认 work/frames)
- * @param {string} [o.clipsDir] 输出目录(默认 work/clips)
- * @param {'auto'|'jimeng'|'still'} [o.mode] 默认 auto(能走即梦就走,否则兜底)
- * @param {number} [o.shotId]
- * @param {number} [o.limit]
- * @param {boolean} [o.force]
- * @param {string} [o.resolution] 720p | 1080p | pro
- */
- export async function generateClips(o = {}) {
- const {
- storyboard,
- keyframesDir = paths.frames,
- clipsDir = paths.clips,
- mode = 'auto',
- shotId,
- limit,
- force = false,
- aspect = storyboard?.aspect || '16:9',
- resolution = process.env.FMODE_VIDEO_RESOLUTION || '720p',
- voiceoverAudio = '',
- workIdLog = join(paths.work, 'workids.log'),
- durationOverride = 0,
- } = o;
- if (!storyboard) throw new Error('generateClips 需要 storyboard');
- mkdirSync(clipsDir, { recursive: true });
- const size = aspectToVideoSize(aspect);
- const provider = getProvider('jimeng');
- const ledger = new CostLedger('图生视频');
- const epKind = resolution === '1080p' ? 'video1080' : resolution === 'pro' ? 'videoPro' : 'video720';
- const unitPrice = ENDPOINTS[epKind].cost;
- // ---- 决定走哪条路 ----
- let useProvider = mode !== 'still';
- let probeNote = null;
- if (mode === 'auto' || mode === 'jimeng') {
- const p = await provider.probe({ deep: false });
- probeNote = p.detail;
- if (!p.available) {
- log.warn(`即梦视频通道不可用:${p.detail}`);
- if (mode === 'jimeng') throw new Error(`即梦通道不可用:${p.detail}`);
- log.warn('转为兜底模式:关键帧 + Ken Burns 运镜(产物带 .fallback 标记,非真视频生成)');
- useProvider = false;
- } else {
- log.info(`即梦通道就绪(${p.detail}),分辨率 ${resolution} ¥${unitPrice}/秒`);
- }
- }
- let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
- if (limit) shots = shots.slice(0, Number(limit));
- // ---- 调用前先给出总费用预估(红线要求) ----
- if (useProvider && shots.length) {
- let totalSec = 0;
- for (const s of shots) totalSec += framesToSeconds(secondsToFrames(durationOverride || s.duration || 5));
- const est = totalSec * unitPrice;
- log.raw('');
- log.raw(` 费用预估:${shots.length} 镜 × 平均 ${(totalSec / shots.length).toFixed(1)}s = ${totalSec.toFixed(1)}s × ¥${unitPrice}/秒 ≈ ¥${est.toFixed(2)}`);
- log.raw(' (即梦接口只接受 121~241 帧,即 5~10 秒/镜;单镜时长会就近吸附)');
- log.raw('');
- }
- const results = [];
- let firstShotDone = false;
- for (const shot of shots) {
- const name = `shot-${String(shot.id).padStart(2, '0')}`;
- const first = join(keyframesDir, `${name}.png`);
- const out = join(clipsDir, `${name}.mp4`);
- const vPrompt = buildVideoPrompt(shot);
- const rawSeconds = durationOverride || shot.duration || 5;
- const frames = secondsToFrames(rawSeconds);
- const seconds = framesToSeconds(frames);
- const durNote = Math.abs(seconds - rawSeconds) > 0.3 ? `(原 ${rawSeconds}s → 吸附为 ${seconds}s,接口限 5~10s)` : '';
- if (existsSync(out) && !force) {
- log.dim(`[${name}] 已存在,跳过(--force 覆盖)`);
- results.push({ id: shot.id, file: out, status: 'skipped', mode: useProvider ? 'jimeng' : 'still', bytes: fileBytes(out) });
- firstShotDone = true;
- continue;
- }
- if (!existsSync(first)) {
- log.err(`[${name}] 缺少关键帧 ${first},先跑 fmode-mv scene(或 fmode-mv keyframe)`);
- results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
- continue;
- }
- // ---------- 真通道:即梦 ----------
- if (useProvider) {
- // 第一镜:若能拿到尾帧(即第 2 镜的关键帧)就用 method=3 做首尾帧转场
- const lastShot = !firstShotDone && shots.length > 1 ? shots[shots.indexOf(shot) + 1] : null;
- const lastFrame = lastShot ? join(keyframesDir, `shot-${String(lastShot.id).padStart(2, '0')}.png`) : null;
- const wantMethod = lastFrame && existsSync(lastFrame) ? '3' : '2';
- log.info(`[${name}] 图生视频 ${seconds}s(${frames} 帧,method=${wantMethod}${wantMethod === '3' ? ' 首尾帧' : ' 首帧'},${size})${durNote}`);
- try {
- const r = await provider.img2video({
- prompt: vPrompt,
- firstImagePath: first,
- lastImagePath: lastFrame && existsSync(lastFrame) ? lastFrame : undefined,
- seconds,
- resolution,
- method: wantMethod,
- outFile: out,
- ledger,
- });
- const st = statSync(out);
- appendWorkIdLog(workIdLog, { type: `video/${resolution}`, workId: r.workId, content: `${name} ${seconds}s method=${r.method}${r.degraded ? '(已从 method=3 降级)' : ''}` });
- log.ok(`→ ${out}(${humanSize(st.size)},method=${r.method}${r.degraded ? ' 已降级' : ''},workId=${r.workId})`);
- results.push({
- id: shot.id,
- file: out,
- status: 'ok',
- mode: 'jimeng',
- method: r.method,
- degraded: !!r.degraded,
- frames: r.frames,
- seconds: r.seconds,
- workId: r.workId,
- bytes: st.size,
- });
- } catch (e) {
- log.err(`[${name}] 即梦图生视频失败:${String(e.message).split('\n')[0]}`);
- log.dim('该镜将留空;可用 --mode still 单独为它做兜底,或稍后重跑。');
- results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'jimeng' });
- }
- firstShotDone = true;
- continue;
- }
- // ---------- 兜底:Ken Burns ----------
- const fbOut = join(clipsDir, `${name}.fallback.mp4`);
- const dir = shot.id % 3 === 0 ? 'out' : 'in';
- try {
- await kenBurns(first, fbOut, {
- duration: seconds,
- width: Number(String(size).split('x')[0]),
- height: Number(String(size).split('x')[1]),
- direction: dir,
- zoom: 0.1,
- });
- const sz = statSync(fbOut).size;
- log.ok(`→ ${fbOut}(${humanSize(sz)},Ken Burns 兜底)`);
- results.push({ id: shot.id, file: fbOut, status: 'ok', mode: 'still', bytes: sz, fallback: true });
- } catch (e) {
- log.err(`[${name}] 兜底生成失败:${String(e.message).split('\n')[0]}`);
- results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'still' });
- }
- firstShotDone = true;
- }
- const out = {
- mode: useProvider ? 'jimeng' : 'still',
- resolution,
- probeNote,
- unitPrice,
- size,
- clips: results,
- cost: { total: ledger.total, entries: ledger.entries },
- workIdLog,
- _meta: { createdBy: 'fmode-mv/video', generatedAt: new Date().toISOString() },
- };
- writeJSON(join(clipsDir, 'clips.json'), out);
- return { results, mode: out.mode, resolution, clipsDir, cost: out.cost, ledger };
- }
- export async function main(argv = process.argv.slice(2)) {
- const { flags } = parseArgs(argv);
- if (flags.help || !flags.from) {
- console.log(`
- fmode-mv video — 图生视频(工序⑥,即梦视频 3.0,✅ 实测可用)
- 用法:
- fmode-mv video --from work/storyboard.json # auto:走即梦真通道
- fmode-mv video --from work/storyboard.json --shot 1 --limit 1 # 只做 1 镜(最省,5s ≈ ¥1.4)
- fmode-mv video --from work/storyboard.json --resolution 1080p # 高清(¥0.63/秒)
- fmode-mv video --from work/storyboard.json --mode still # 强制 Ken Burns 兜底(零 API 费用)
- fmode-mv video --from work/storyboard.json --force # 覆盖已存在片段
- 选项:
- --from <file> 分镜 JSON
- --mode auto|jimeng|still 默认 auto(通道不通自动降级为兜底)
- --resolution 720p|1080p|pro 默认 720p(¥0.28/秒;1080p ¥0.63;pro ¥1)
- --shot <id> 只做指定镜头
- --limit <n> 只做前 n 镜(试跑省钱)
- --frames <dir> 关键帧目录(默认 work/frames)
- --out <dir> 输出目录(默认 work/clips)
- --duration <秒> 覆盖单镜时长(会吸附到接口允许的 5~10 秒)
- --force 覆盖已存在的片段
- 硬规则(已内置,无需手动处理):
- · frames = 24 × 秒数 + 1,接口只接受 [121, 241] → 单镜实际 5~10 秒
- · 第一镜优先 method=3(首尾帧,用下一镜的关键帧作尾帧);失败自动降级 method=2
- · 每镜 prompt 含「音效 + 运镜 + 4k 画质词」
- 产出:
- work/clips/shot-XX.mp4(兜底产物为 shot-XX.fallback.mp4)
- work/clips/clips.json(每镜 method / workId / 费用 / 是否降级)
- work/workids.log(workId 台账,格式:[时间戳] 类型 | workId: xxx | 内容: xxx)
- 费用参考: 720p ¥0.28/秒(5 秒镜 ≈ ¥1.41,实测);整片 8 镜 ≈ ¥11
- `);
- return 0;
- }
- checkFfmpeg();
- ensureWork();
- const from = String(flags.from);
- if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
- const storyboard = readJSON(from);
- log.step(`生成视频片段:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
- const r = await generateClips({
- storyboard,
- keyframesDir: String(flags.frames || paths.frames),
- clipsDir: String(flags.out || paths.clips),
- mode: flags.mode || 'auto',
- shotId: flags.shot ? Number(flags.shot) : undefined,
- limit: flags.limit ? Number(flags.limit) : undefined,
- force: !!flags.force,
- resolution: String(flags.resolution || process.env.FMODE_VIDEO_RESOLUTION || '720p'),
- durationOverride: Number(flags.duration) || 0,
- });
- const ok = r.results.filter((x) => x.status === 'ok').length;
- const fb = r.results.filter((x) => x.fallback).length;
- const skipped = r.results.filter((x) => x.status === 'skipped').length;
- const failed = r.results.filter((x) => x.status === 'failed').length;
- const degraded = r.results.filter((x) => x.degraded).length;
- log.raw('');
- log.raw(r.ledger.summary());
- log.key('模式', r.mode === 'jimeng' ? `即梦真通道(${r.resolution})` : '兜底(Ken Burns)');
- log.key('结果', `成功 ${ok}(兜底 ${fb} / 降级 ${degraded})/ 跳过 ${skipped} / 失败 ${failed}`);
- if (r.mode === 'still' && fb > 0) {
- log.warn('注意:本次片段是「静帧 + 运镜」兜底产物,不是 AI 图生视频结果。');
- }
- return failed > 0 && 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);
- });
- }
- export { framesToSeconds, secondsToFrames, VIDEO_FRAMES, JIMENG };
|