cang-ying/engines/video-composer.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

174 lines
5.8 KiB
JavaScript
Raw 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系统 · 视频合成引擎
* D135 · 铸渊 ICE-GL-ZY001
*
* 用途: 将多个镜头的 mp4 文件按时间线拼接为成品视频。
* 支持硬切、溶解过渡、截取、加速、静音轨。
* 底层调用 FFmpeg零额外成本。
*
* 使用方式:
* const { compose } = require('./video-composer');
* await compose({
* shots: [{ file, trim: 2.5 }, ...],
* transition: 'fade', // 'cut' | 'fade'
* fadeDuration: 0.3,
* output: '/Volumes/JZAO/.../output.mp4',
* });
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// ==================== 默认输出 ====================
const DEFAULT_OUTPUT_DIR = (() => {
const jzao = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频';
return fs.existsSync(jzao) ? jzao : path.resolve(__dirname, '../outputs');
})();
// ==================== 核心 ====================
/**
* 合成视频
* @param {object[]} opts.shots - 镜头列表 [{ file, trim: 秒数 }, ...]
* @param {string} [opts.transition] - 'cut' 硬切(默认) | 'fade' 溶解
* @param {number} [opts.fadeDuration] - 溶解时长秒数 (默认0.3)
* @param {string} [opts.output] - 输出路径
* @param {number} [opts.fps] - 帧率 (默认与首镜相同)
* @returns {Promise<{outputPath: string, duration: number, size: string}>}
*/
async function compose({ shots, transition = 'fade', fadeDuration = 0.3, output, fps }) {
if (!shots || shots.length === 0) throw new Error('镜头列表不能为空');
if (shots.length === 1) {
const src = shots[0].file;
const dest = output || path.join(DEFAULT_OUTPUT_DIR, `composed-${Date.now()}.mp4`);
fs.copyFileSync(src, dest);
const dur = probeSec(dest);
return { outputPath: dest, duration: dur, size: fmtSize(dest) };
}
const tmpDir = path.join(DEFAULT_OUTPUT_DIR, '.composer-tmp');
fs.mkdirSync(tmpDir, { recursive: true });
// Step1: 截取每镜到目标时长,统一帧率和编码
const trimmed = [];
for (let i = 0; i < shots.length; i++) {
const s = shots[i];
const tFile = path.join(tmpDir, `trim_${i}_${Date.now()}.mp4`);
const trimDur = s.trim || probeSec(s.file);
const fpsFlag = fps ? `-r ${fps}` : '';
execSync(
`ffmpeg -y -i "${s.file}" -t ${trimDur} ${fpsFlag} -c:v libx264 -preset ultrafast -crf 18 -an "${tFile}" 2>/dev/null`,
{ timeout: 30000 }
);
if (!fs.existsSync(tFile)) throw new Error(`截取失败: 镜${i + 1}`);
trimmed.push({ file: tFile, dur: trimDur });
}
// Step2: 拼接
const finalPath = output || path.join(DEFAULT_OUTPUT_DIR, `composed-${Date.now()}.mp4`);
if (transition === 'fade' && trimmed.length >= 2) {
await composeXFade(trimmed, finalPath, fadeDuration);
} else {
await composeCut(trimmed, finalPath);
}
// Step3: faststart播放器优化失败不影响
try {
const tmp = finalPath + '.tmp';
execSync(`ffmpeg -y -i "${finalPath}" -c copy -movflags +faststart "${tmp}" 2>/dev/null`, { timeout: 15000 });
if (fs.existsSync(tmp)) { fs.unlinkSync(finalPath); fs.renameSync(tmp, finalPath); }
} catch (_) { /* faststart is optional */ }
// Step4: 清理临时文件
trimmed.forEach(t => { try { fs.unlinkSync(t.file); } catch (_) {} });
const dur = probeSec(finalPath);
console.log(`[Composer] ✅ ${finalPath} ${dur.toFixed(1)}s ${fmtSize(finalPath)}`);
return { outputPath: finalPath, duration: dur, size: fmtSize(finalPath) };
}
/**
* 硬切拼接(无过渡)
*/
function composeCut(trimmed, output) {
const listFile = path.join(path.dirname(output), '.concat-list.txt');
const list = trimmed.map(t => `file '${t.file}'`).join('\n');
fs.writeFileSync(listFile, list);
execSync(
`ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${output}" 2>/dev/null`,
{ timeout: 30000 }
);
try { fs.unlinkSync(listFile); } catch (_) {}
}
/**
* 溶解过渡拼接
*/
function composeXFade(trimmed, output, fadeDur) {
if (trimmed.length === 2) {
// 两镜:直接 xfade
execSync(
`ffmpeg -y -i "${trimmed[0].file}" -i "${trimmed[1].file}" ` +
`-filter_complex "xfade=transition=fade:duration=${fadeDur}:offset=${(trimmed[0].dur - fadeDur).toFixed(1)}" ` +
`-c:v libx264 -preset fast -crf 23 -an "${output}" 2>/dev/null`,
{ timeout: 60000 }
);
return;
}
// 多镜:链式 xfade
let filterLines = '';
let prevLabel = '0:v';
let totalDur = 0;
const offsets = [];
for (let i = 0; i < trimmed.length; i++) {
if (i > 0) {
const offset = (totalDur - fadeDur).toFixed(1);
const newLabel = i < trimmed.length - 1 ? `vt${i}` : 'vout';
filterLines += `[${prevLabel}][${i}:v]xfade=transition=fade:duration=${fadeDur}:offset=${offset}[${newLabel}];\n`;
prevLabel = newLabel;
totalDur += trimmed[i].dur - fadeDur;
} else {
totalDur = trimmed[i].dur;
}
}
const inputFlags = trimmed.map((_, i) => `-i "${trimmed[i].file}"`).join(' ');
const fullFilter = filterLines.trim();
const mapOut = trimmed.length > 2 ? 'vout' : '1:v';
execSync(
`ffmpeg -y ${inputFlags} -filter_complex "${fullFilter}" ` +
`-map "[${prevLabel}]" -c:v libx264 -preset fast -crf 23 -an "${output}" 2>/dev/null`,
{ timeout: 120000 }
);
}
// ==================== 工具 ====================
function probeSec(file) {
try {
const out = execSync(
`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${file}"`,
{ timeout: 5000, encoding: 'utf8' }
);
return parseFloat(out.trim());
} catch (_) {
return 4.0; // fallback
}
}
function fmtSize(file) {
try {
const stat = fs.statSync(file);
return stat.size < 1048576
? `${(stat.size / 1024).toFixed(0)}KB`
: `${(stat.size / 1048576).toFixed(1)}MB`;
} catch (_) { return '?'; }
}
module.exports = { compose, probeSec };