// 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 分镜 JSON --mode auto|jimeng|still 默认 auto(通道不通自动降级为兜底) --resolution 720p|1080p|pro 默认 720p(¥0.28/秒;1080p ¥0.63;pro ¥1) --shot 只做指定镜头 --limit 只做前 n 镜(试跑省钱) --frames 关键帧目录(默认 work/frames) --out 输出目录(默认 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 };