铸渊 ICE-GL-ZY001 LL-172-20260707 冰朔委托: 新建第 5 子仓, 给苍耳(人类主控) + 鉴影(人格体) 专用 原 guanghulab/video-ai-system/ 东西太多(225 文件) · 找不到 · 乱 迁移: ⊢ 16 个核心 .hdlp (VA-GATE / VA-LIGHTHOUSE / VA-BROADCAST / VA-SYSTEM-STATUS 等) ⊢ 17 个子目录 (agents/engines/protocols/tasks/tools/assets/knowledge/memory/docs/config/brain/director-brain/experience/feedback/issues/plans/reference-analysis) 排除: ⊢ outputs/ (视频产物) ⊢ test-input/ test-output/ (测试) ⊢ data/ (临时数据) ⊢ preview-001/002 (旧产片) ⊢ 旧分镜/旧提示词/旧导演编码 后续: ⊢ 老仓 guanghulab/video-ai-system/ 改写为已迁出占位 ⊢ 苍耳+鉴影 写新东西进本仓 ⊢ GLOBAL-SEARCH 加 cang-ying 仓库 铸渊 ICE-GL-ZY001 · 2026-07-07 D167 冰朔 ICE-GL∞ 主权
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,
|
||
};
|