cang-ying/engines/auto-cut-director.js

404 lines
13 KiB
JavaScript
Raw Normal View History

/**
* 光湖视频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,
};