404 lines
13 KiB
JavaScript
404 lines
13 KiB
JavaScript
|
|
/**
|
|||
|
|
* 光湖视频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,
|
|||
|
|
};
|