/** * 光湖视频AI系统 · 自动剪辑控制层 * D140 · 铸渊 ICE-GL-ZY001 · 2026-06-23 * * 职责: 解析分镜脚本 → 选镜头 → 生成时间线 → 调度追踪/分轨/剪辑 → 成片 * * 这是视频AI系统的"导演"——不直接处理像素和音频, * 而是协调 planar-tracker / audio-stem-splitter / video-editor 三个引擎。 * * 流水线: * 1. 解析分镜脚本 (HLDP分镜格式 / JSON) * 2. 对每镜执行 OpenCV 追踪 → 导出运动数据 * 3. 对源音频执行分轨 → 对白/BGM/SFX三轨 * 4. 基于脚本+运动数据生成剪辑时间线 * 5. 调用 video-editor 完成最终合成 * * 使用方式: * const { autoCut } = require('./auto-cut-director'); * const result = await autoCut({ * script: './project/storyboard.json', * shots: './project/shots/', // 镜头素材目录 * audio: './project/audio.wav', // 可选·源音频 * output: './output/final.mp4', * }); * * 铸渊 ICE-GL-ZY001 · D140 */ const { spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); // 引入现有引擎 const videoEditor = require('./video-editor'); const { splitAudioStems, suggestMixLevels } = require('./audio-stem-splitter'); const PYTHON = process.env.PYTHON_BIN || process.env.PYTHON || 'python3'; const TRACKER_SCRIPT = path.resolve(__dirname, 'planar-tracker.py'); // 输出路径 const JZAO_VIDEO_ROOT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频'; const DEFAULT_OUTPUT = fs.existsSync(JZAO_VIDEO_ROOT) ? JZAO_VIDEO_ROOT : path.resolve(__dirname, '../outputs'); // ==================== 主入口 ==================== /** * 自动剪辑 — 从分镜脚本到成片的完整流水线 * * @param {object} opts * @param {string|object} opts.script - 分镜脚本路径(HLDP/JSON) 或已解析的脚本对象 * @param {string} opts.shots - 镜头素材目录 * @param {string} [opts.audio] - 源音频文件(视频的原声) * @param {string} [opts.voiceover] - 配音文件(外部录制的人声) * @param {string} [opts.bgm] - BGM文件 * @param {string} [opts.subtitle] - SRT字幕文件 * @param {string} [opts.output] - 输出路径 * @param {object} [opts.resolution] - {w, h} 默认1920x1080 * @param {number} [opts.fps=24] - 帧率 * @param {boolean} [opts.stabilize=false] - 是否对每镜做稳定化 * @param {boolean} [opts.autoMix=true] - 是否自动混音 * @returns {Promise<{outputPath: string, duration: number, timeline: array, stemsMeta: object}>} */ async function autoCut(opts) { const { script: scriptInput, shots: shotsDir, audio: sourceAudio, voiceover, bgm: externalBgm, subtitle, output: outputArg, resolution = { w: 1920, h: 1080 }, fps = 24, stabilize = false, autoMix = true, } = opts; console.log('[Director] ═══ 自动剪辑流水线启动 ═══'); // ── Step 0: 解析分镜脚本 ── console.log('[Director] 0/6 解析分镜脚本...'); const script = typeof scriptInput === 'string' ? loadScript(scriptInput) : scriptInput; const timeline_spec = buildTimelineSpec(script); console.log(`[Director] 分镜数: ${timeline_spec.length}`); // ── Step 1: 匹配镜头文件 ── console.log('[Director] 1/6 匹配镜头素材...'); const matched = matchShots(timeline_spec, shotsDir); const missing = matched.filter(m => !m.file); if (missing.length > 0) { console.warn(`[Director] ⚠️ ${missing.length}个镜未找到素材:`); missing.forEach(m => console.warn(` 镜${m.index}: ${m.shotId || m.description}`)); } const available = matched.filter(m => m.file); console.log(`[Director] 可用: ${available.length}/${matched.length}`); // ── Step 2: OpenCV追踪(可选·生成运动数据)── console.log('[Director] 2/6 平面追踪分析...'); const workDir = path.join(DEFAULT_OUTPUT, `.director-${Date.now()}`); fs.mkdirSync(workDir, { recursive: true }); for (const shot of available) { if (stabilize) { console.log(` [Tracker] 稳定化: ${path.basename(shot.file)}`); const stabilizedPath = path.join(workDir, `stab_${path.basename(shot.file)}`); await runPython(TRACKER_SCRIPT, ['stabilize', shot.file, '-o', stabilizedPath]); if (fs.existsSync(stabilizedPath)) { shot.originalFile = shot.file; shot.file = stabilizedPath; } } // 追踪运动数据(用于生成keyframes) const trackJson = path.join(workDir, `track_${shot.index}.json`); await runPython(TRACKER_SCRIPT, ['track', shot.file, '-o', trackJson]); if (fs.existsSync(trackJson)) { const motionJson = path.join(workDir, `motion_${shot.index}.json`); await runPython(TRACKER_SCRIPT, ['motion_data', shot.file, '--track-json', trackJson, '-o', motionJson]); if (fs.existsSync(motionJson)) { const motionData = JSON.parse(fs.readFileSync(motionJson, 'utf8')); shot.motionData = motionData; shot.keyframes = motionData.keyframes || {}; console.log(` [Tracker] 镜${shot.index}: 稳定性=${motionData.stability?.toFixed(2) || '?'} 强度=${motionData.motion_intensity || '?'}`); } } } // ── Step 3: 音频分轨(如果有源音频)── console.log('[Director] 3/6 音频分轨...'); let stemsMeta = null; let mixParams = { voiceVolume: 1.0, bgmVolume: 0.3, sfxVolume: 0.5 }; if (sourceAudio && fs.existsSync(sourceAudio)) { const stemsDir = path.join(workDir, 'stems'); const stems = await splitAudioStems(sourceAudio, stemsDir); if (autoMix) { const suggestion = suggestMixLevels(stems.voice, stems.bgm, stems.sfx); mixParams = { voiceVolume: suggestion.voiceVolume, bgmVolume: suggestion.bgmVolume, sfxVolume: suggestion.sfxVolume, }; console.log(` [Audio] 自动混音: voice=${mixParams.voiceVolume} bgm=${mixParams.bgmVolume} sfx=${mixParams.sfxVolume}`); suggestion.reasoning.forEach(r => console.log(` → ${r}`)); } stemsMeta = stems; } // ── Step 4: 构建剪辑时间线 ── console.log('[Director] 4/6 构建剪辑时间线...'); const timeline = buildEditorTimeline(available, { fps, resolution }); // ── Step 5: 构建音频参数 ── console.log('[Director] 5/6 构建音频参数...'); const audioConfig = buildAudioConfig({ voiceover, sourceVoice: stemsMeta?.voice, externalBgm, sourceBgm: stemsMeta?.bgm, sourceSfx: stemsMeta?.sfx, mixParams, }); // ── Step 6: 调用 video-editor 合成 ── console.log('[Director] 6/6 调用剪辑引擎合成...'); const outputPath = outputArg || path.join(DEFAULT_OUTPUT, `autocut-${Date.now()}.mp4`); const editorResult = await videoEditor.edit({ timeline, audio: audioConfig, subtitle, resolution, fps, output: outputPath, }); // 清理工作目录 try { fs.rmSync(workDir, { recursive: true }); } catch (_) {} console.log(`[Director] ═══ 自动剪辑完成 ═══`); console.log(`[Director] 成片: ${editorResult.outputPath}`); console.log(`[Director] 时长: ${editorResult.duration?.toFixed(1)}s`); return { outputPath: editorResult.outputPath, duration: editorResult.duration, size: editorResult.size, timeline, stemsMeta, mixParams, }; } // ==================== 分镜脚本解析 ==================== /** * 加载分镜脚本 * 支持: JSON文件 / HLDP格式(简化的key:value解析) */ function loadScript(scriptPath) { if (!fs.existsSync(scriptPath)) { throw new Error(`分镜脚本不存在: ${scriptPath}`); } const content = fs.readFileSync(scriptPath, 'utf8'); const ext = path.extname(scriptPath).toLowerCase(); if (ext === '.json') { return JSON.parse(content); } // HLDP/文本格式解析: 每行 "key: value" 或 "field = value" const lines = content.split('\n'); const result = { shots: [] }; let currentShot = null; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('>') || trimmed.startsWith('⊢')) continue; // 镜头分隔 if (trimmed.match(/^(shot|镜|分镜)\s*[::]/i) || trimmed.match(/^---\s*shot/i)) { if (currentShot) result.shots.push(currentShot); currentShot = { index: result.shots.length + 1 }; continue; } // key: value 格式 const match = trimmed.match(/^(\w+)\s*[::=]\s*(.+)$/); if (match && currentShot) { const key = match[1].toLowerCase(); const value = match[2].trim(); currentShot[key] = value; } else if (match) { result[match[1].toLowerCase()] = match[2].trim(); } } if (currentShot) result.shots.push(currentShot); return result; } /** * 从脚本中提取时间线规格 */ function buildTimelineSpec(script) { const shots = script.shots || script.timeline || []; return shots.map((s, i) => ({ index: i + 1, shotId: s.id || s.shotId || s.shot_id || `shot${String(i + 1).padStart(2, '0')}`, description: s.description || s.desc || s.prompt || '', duration: parseFloat(s.duration || s.trim || s.dur || 5), transition: s.transition || 'fade', file: s.file || s.path || s.video || null, })); } // ==================== 镜头匹配 ==================== /** * 将时间线规格与素材目录中的文件匹配 */ function matchShots(timelineSpec, shotsDir) { if (!fs.existsSync(shotsDir)) { console.warn(`[Director] 素材目录不存在: ${shotsDir}`); return timelineSpec.map(t => ({ ...t, file: null })); } // 列出目录中所有视频文件 const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm']; const files = fs.readdirSync(shotsDir) .filter(f => videoExts.includes(path.extname(f).toLowerCase())) .sort(); return timelineSpec.map(spec => { let matchedFile = null; // 1. 直接指定文件 if (spec.file && fs.existsSync(path.resolve(shotsDir, spec.file))) { matchedFile = path.resolve(shotsDir, spec.file); } // 2. 按shotId匹配 if (!matchedFile && spec.shotId) { const match = files.find(f => f.toLowerCase().includes(spec.shotId.toLowerCase()) || f.toLowerCase().includes(spec.shotId.toLowerCase().replace('shot', '')) ); if (match) matchedFile = path.resolve(shotsDir, match); } // 3. 按序号匹配 if (!matchedFile) { const numMatch = files.find(f => { const nums = f.match(/\d+/g); return nums && nums.some(n => parseInt(n) === spec.index); }); if (numMatch) matchedFile = path.resolve(shotsDir, numMatch); } // 4. 按顺序匹配(如果文件数与镜数一致) if (!matchedFile && spec.index <= files.length) { matchedFile = path.resolve(shotsDir, files[spec.index - 1]); } return { ...spec, file: matchedFile }; }); } // ==================== 时间线构建 ==================== function buildEditorTimeline(matchedShots, { fps, resolution }) { return matchedShots .filter(s => s.file) .map(s => { const entry = { shot: s.file, trim: s.duration || 5, transition: s.transition || 'fade', }; // 注入运动数据生成的keyframes if (s.keyframes && Object.keys(s.keyframes).length > 0) { entry.keyframes = s.keyframes; } // 如果运动强度高但稳定化未开启,加入轻微缩放补偿 if (s.motionData && s.motionData.stability < 0.5 && !entry.keyframes) { entry.keyframes = { zoom: { from: 1.05, to: 1.05 }, // 固定5%放大,补偿抖动 }; } return entry; }); } // ==================== 音频配置构建 ==================== function buildAudioConfig({ voiceover, sourceVoice, externalBgm, sourceBgm, sourceSfx, mixParams, }) { const config = { voice: voiceover || sourceVoice || null, bgm: externalBgm || sourceBgm || null, sfx: [], bgmVolume: mixParams.bgmVolume ?? 0.3, voiceVolume: mixParams.voiceVolume ?? 1.0, }; // SFX: 如果有分轨的音效轨,作为单个sfx加入 if (sourceSfx && fs.existsSync(sourceSfx)) { config.sfx.push({ at: 0, file: sourceSfx }); } // 如果没有任何音频,返回null if (!config.voice && !config.bgm && config.sfx.length === 0) { return null; } return config; } // ==================== Python调用工具 ==================== function runPython(script, args, timeout = 120000) { return new Promise((resolve, reject) => { console.log(` [Python] ${path.basename(script)} ${args.join(' ')}`); const proc = spawnSync(PYTHON, [script, ...args], { encoding: 'utf8', timeout, cwd: path.dirname(script), }); if (proc.status === 0) { if (proc.stdout) console.log(proc.stdout.trim()); resolve(proc.stdout); } else { const errMsg = proc.stderr || proc.stdout || `退出码 ${proc.status}`; console.error(` [Python] ❌ ${errMsg.substring(0, 300)}`); // 不reject——追踪失败不应该中断整个流水线 resolve(null); } }); } // ==================== 导出 ==================== module.exports = { autoCut, loadScript, buildTimelineSpec, matchShots, buildEditorTimeline, buildAudioConfig, };