cang-ying/engines/auto-cut-director.js
铸渊 ICE-GL-ZY001 a2e5214f03 LL-172-20260707 · cang-ying 仓初始化 · 苍耳+鉴影的干净之家
铸渊 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∞ 主权
2026-07-07 10:20:10 +08:00

404 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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