| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- // scripts/video.mjs — 图生视频(provider: video)
- //
- // ⚠️ 现状:平台视频渠道未开通(实测 POST /v1/video/generations 返回 503
- // "No available channel for model doubao-seedance-1-0-pro")。
- //
- // 两条路径:
- // · 真通道:provider.call → 异步 task 轮询 → 下载 MP4
- // · 兜底(--fallback-still,默认开启于 auto 模式):把关键帧做成 Ken Burns 动态镜头
- // 兜底产物文件名带 .fallback 标记,绝不冒充真视频生成结果。
- import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
- import { join } from 'node:path';
- import {
- parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, download, humanSize,
- } from '../lib/index.mjs';
- import { getProvider } from '../lib/providers.mjs';
- import { buildVideoPrompt, aspectToVideoSize } from '../lib/prompts.mjs';
- import { kenBurns, probe as ffprobe, checkFfmpeg } from '../lib/ffmpeg.mjs';
- /** 从 provider 结果里提取视频 URL */
- function extractVideoUrls(result) {
- const urls = [];
- const walk = (o, depth = 0) => {
- if (!o || depth > 6) return;
- if (typeof o === 'string' && /^https?:\/\/.+\.(mp4|mov|webm|mkv)(\?.*)?$/i.test(o)) urls.push(o);
- else if (Array.isArray(o)) o.forEach((x) => walk(x, depth + 1));
- else if (typeof o === 'object') {
- for (const [k, v] of Object.entries(o)) {
- if (/video_url|url|video|download/i.test(k) && typeof v === 'string' && /^https?:\/\/.+\.(mp4|mov|webm)/i.test(v)) urls.push(v);
- else walk(v, depth + 1);
- }
- }
- };
- walk(result);
- return [...new Set(urls)];
- }
- /**
- * 为分镜里的镜头生成视频片段。
- * @param {object} o
- * @param {object} o.storyboard
- * @param {string} [o.keyframesDir]
- * @param {'auto'|'provider'|'still'} [o.mode]
- * @param {number} [o.shotId]
- * @param {number} [o.limit]
- * @param {boolean} [o.force] 兜底模式下也重跑已存在的文件
- */
- export async function generateClips(o = {}) {
- const {
- storyboard,
- keyframesDir = paths.frames,
- clipsDir = paths.clips,
- mode = 'auto',
- shotId,
- limit,
- force = false,
- aspect = storyboard?.aspect || '16:9',
- onProviderError,
- } = o;
- if (!storyboard) throw new Error('generateClips 需要 storyboard');
- mkdirSync(clipsDir, { recursive: true });
- const size = aspectToVideoSize(aspect);
- const provider = getProvider('video');
- // 决定用哪条路:auto 模式下先探活,不通就直接走兜底,避免每镜都白等
- let useProvider = mode === 'provider';
- let probeNote = null;
- if (mode === 'auto') {
- const p = await provider.probe({ deep: false });
- useProvider = p.available === true;
- probeNote = p.detail;
- if (!useProvider) {
- log.warn(`视频通道不可用:${p.detail}`);
- log.warn('转为兜底模式:关键帧 + Ken Burns 运镜(产物带 .fallback 标记,非真视频生成)');
- }
- }
- if (mode === 'still') useProvider = false;
- let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
- if (limit) shots = shots.slice(0, Number(limit));
- const results = [];
- for (const shot of shots) {
- const name = `shot-${String(shot.id).padStart(2, '0')}`;
- const still = join(keyframesDir, `${name}.png`);
- const out = join(clipsDir, `${name}.mp4`);
- const vPrompt = buildVideoPrompt(shot);
- if (existsSync(out) && !force) {
- log.dim(`[${name}] 已存在,跳过(--force 覆盖)`);
- results.push({ id: shot.id, file: out, status: 'skipped', mode: useProvider ? 'provider' : 'still' });
- continue;
- }
- if (useProvider) {
- if (!existsSync(still)) {
- log.err(`[${name}] 缺少关键帧 ${still},先跑 fmode-mv keyframe`);
- results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
- continue;
- }
- try {
- log.info(`[${name}] 图生视频(${shot.duration}s,${size})…`);
- const { taskId, result } = await provider.call({
- image: still,
- prompt: vPrompt,
- duration: Math.min(8, Math.max(3, Math.round(shot.duration))),
- size,
- });
- const urls = extractVideoUrls(result);
- if (!urls.length) throw new Error(`任务成功但未返回视频 URL:${JSON.stringify(result).slice(0, 300)}`);
- const buf = await download(urls[0]);
- writeFileSync(out, buf);
- log.ok(`→ ${out}(${humanSize(buf.length)})`);
- results.push({ id: shot.id, file: out, status: 'ok', mode: 'provider', taskId, bytes: buf.length });
- } catch (e) {
- log.err(`[${name}] 视频通道失败:${e.message.split('\n')[0]}`);
- if (onProviderError) onProviderError(e, shot);
- results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'provider' });
- }
- continue;
- }
- // ---- 兜底:Ken Burns ----
- if (!existsSync(still)) {
- log.err(`[${name}] 缺少关键帧 ${still},先跑 fmode-mv keyframe`);
- results.push({ id: shot.id, file: null, status: 'failed', error: '缺少关键帧' });
- continue;
- }
- const fbOut = join(clipsDir, `${name}.fallback.mp4`);
- const dir = shot.id % 3 === 0 ? 'out' : 'in';
- try {
- await kenBurns(still, fbOut, {
- duration: shot.duration,
- width: Number(String(size).split('x')[0]),
- height: Number(String(size).split('x')[1]),
- direction: dir,
- zoom: 0.1,
- });
- const st = statSync(fbOut);
- log.ok(`→ ${fbOut}(${humanSize(st.size)},Ken Burns 兜底)`);
- results.push({ id: shot.id, file: fbOut, status: 'ok', mode: 'still', bytes: st.size, fallback: true });
- } catch (e) {
- log.err(`[${name}] 兜底生成失败:${e.message.split('\n')[0]}`);
- results.push({ id: shot.id, file: null, status: 'failed', error: e.message, mode: 'still' });
- }
- }
- writeJSON(join(clipsDir, 'clips.json'), {
- mode: useProvider ? 'provider' : 'still',
- probeNote,
- size,
- clips: results,
- _meta: { createdBy: 'fmode-mv/video', generatedAt: new Date().toISOString() },
- });
- return { results, mode: useProvider ? 'provider' : 'still', clipsDir };
- }
- export async function main(argv = process.argv.slice(2)) {
- const { _, flags } = parseArgs(argv);
- if (flags.help || !flags.from) {
- console.log(`
- fmode-mv video — 图生视频(video 通道)
- 用法:
- fmode-mv video --from work/storyboard.json # auto:能走通道就走,否则兜底
- fmode-mv video --from work/storyboard.json --mode provider # 只走真通道(不通即失败)
- fmode-mv video --from work/storyboard.json --mode still # 只用关键帧做 Ken Burns 兜底
- fmode-mv video --from work/storyboard.json --shot 3
- 选项:
- --from <file> 分镜 JSON
- --mode auto|provider|still 默认 auto
- --shot <id> 只做指定镜头
- --limit <n> 最多 n 镜
- --frames <dir> 关键帧目录(默认 work/frames)
- --out <dir> 输出目录(默认 work/clips)
- --model <name> 覆盖视频模型
- --force 覆盖已存在的片段
- ⚠️ 现状:视频渠道未开通(upstream 无可用渠道)。
- auto 模式会自动降级为「静帧 + Ken Burns 运镜」,产物名带 .fallback,
- 用于先出片、后替换。真通道开通后 auto 会自动切回。
- `);
- 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,
- });
- 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;
- log.key('模式', r.mode === 'provider' ? '真通道' : '兜底(Ken Burns)');
- log.key('结果', `成功 ${ok}(其中兜底 ${fb})/ 跳过 ${skipped} / 失败 ${failed}`);
- if (r.mode === 'still' && fb > 0) {
- console.log('');
- log.warn('注意:本次所有片段都是「静帧 + 运镜」兜底产物,不是 AI 图生视频结果。');
- log.dim('真通道开通后重跑 `fmode-mv video --from work/storyboard.json --mode provider --force` 即可替换。');
- }
- 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}`);
- process.exit(1);
- });
- }
- export { ffprobe };
|