// 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 分镜 JSON --mode auto|provider|still 默认 auto --shot 只做指定镜头 --limit 最多 n 镜 --frames 关键帧目录(默认 work/frames) --out 输出目录(默认 work/clips) --model 覆盖视频模型 --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 };