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

394 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系统 · 视频剪辑引擎 v2.0
* D136+ · 铸渊 ICE-GL-ZY001
*
* 基于 video-composer.js (D135) 升级。
* 差异composer 只能拼。editor 能剪。
*
* composer: [视频] + [视频] + fade = 拼接
* editor: [视频×N] + [配音] + [BGM] + [字幕] = 成品
*
* 全 ffmpeg 驱动。零 GUI 依赖。每一个参数铸渊能改。
*
* 使用方式:
* const { edit } = require('./video-editor');
* await edit({
* timeline: [
* { shot: 'shot01.mp4', trim: 3.5, transition: 'fade',
* keyframes: { zoom: { from: 1.0, to: 1.15 }, pan: { from: [0,0], to: [60,-80] } } },
* { shot: 'shot02.mp4', trim: 2.0, transition: 'cut' },
* ],
* audio: {
* voice: 'voiceover.wav', // 配音
* bgm: 'bgm.mp3', // 背景音乐
* sfx: [{ at: 2.5, file: 'hit.wav' }], // 音效
* bgmVolume: 0.3, // BGM音量
* voiceVolume: 1.0,
* },
* subtitle: 'subtitles.srt', // SRT字幕文件
* colorGrade: {
* brightness: 0, contrast: 1.1, saturation: 1.05,
* },
* resolution: { w: 1920, h: 1080 }, // 统一分辨率
* fps: 24,
* output: './output/final.mp4',
* });
*/
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// ==================== 输出目录 ====================
const OUT = '/Volumes/JZAO/铸渊-ICE-GL-ZY001/OUT-输出/视频';
const DEFAULT_OUT = fs.existsSync(OUT) ? OUT : path.resolve(__dirname, '../outputs');
// ==================== 主入口 ====================
/**
* 一站式剪辑
*/
async function edit({
timeline, audio, subtitle, colorGrade,
resolution, fps, output,
}) {
if (!timeline || timeline.length === 0) throw new Error('timeline 不能为空');
const w = resolution?.w || 1920;
const h = resolution?.h || 1080;
const fpsVal = fps || 24;
const color = colorGrade || {};
const tmp = path.join(DEFAULT_OUT, `.editor-${Date.now()}`);
fs.mkdirSync(tmp, { recursive: true });
const outPath = output || path.join(DEFAULT_OUT, `edited-${Date.now()}.mp4`);
console.log('[Editor] ─── 剪辑开始 ───');
console.log(`[Editor] 镜数: ${timeline.length} | ${w}×${h} @${fpsVal}fps`);
// ── Step 1: 预处理每镜 ──
console.log('[Editor] 1/5 预处理镜头...');
const prepped = [];
for (let i = 0; i < timeline.length; i++) {
const t = timeline[i];
const pFile = path.join(tmp, `prep_${i}.mp4`);
const dur = t.trim || probeSec(t.shot);
prepShot(t.shot, pFile, { w, h, fps: fpsVal, dur, color, keyframes: t.keyframes });
prepped.push({ file: pFile, dur, transition: t.transition || 'fade' });
console.log(`${i + 1}: ${path.basename(t.shot)} → trim ${dur}s`);
}
// ── Step 2: 拼接视频轨 ──
console.log('[Editor] 2/5 拼接视频轨...');
const videoOnly = path.join(tmp, 'video_track.mp4');
await composeTimeline(prepped, videoOnly, fpsVal);
// ── Step 3: 处理音频轨 ──
let videoWithAudio = videoOnly;
if (audio) {
console.log('[Editor] 3/5 合成音频轨...');
const audioMix = path.join(tmp, 'audio_mix.wav');
await mixAudioTrack(audio, audioMix, probeSec(videoOnly));
videoWithAudio = path.join(tmp, 'video_audio.mp4');
mergeAudio(videoOnly, audioMix, videoWithAudio);
}
// ── Step 4: 烧录字幕 ──
let final = videoWithAudio;
if (subtitle && fs.existsSync(subtitle)) {
console.log('[Editor] 4/5 烧录字幕...');
final = path.join(tmp, 'subtitled.mp4');
burnSubtitles(videoWithAudio, subtitle, final);
} else {
console.log('[Editor] 4/5 无字幕·跳过');
}
// ── Step 5: 最终编码 ──
console.log('[Editor] 5/5 最终编码...');
finalEncode(final, outPath);
// 统计
const d = probeSec(outPath);
const s = fmtSize(outPath);
console.log(`[Editor] ✅ ${outPath}\n[Editor] 时长: ${d.toFixed(1)}s 大小: ${s}`);
// 清理
try { fs.rmSync(tmp, { recursive: true }); } catch (_) {}
return { outputPath: outPath, duration: d, size: s };
}
// ==================== Step 1: 镜头预处理 ====================
/**
* 预处理单镜:统一分辨率/帧率/调色 → 关键帧动画 → 输出标准化片段
*
* keyframes 支持三种动画叠加:
* zoom: { from: 1.0, to: 1.2 } → 慢推 20%
* pan: { from: [0,0], to: [80,-40] } → 平移 (px, 从画面中心)
* rotate:{ from: 0, to: 3 } → 缓转 3°
*
* 示例: 镜1从全景慢推到苏白面部
* keyframes: { zoom: { from: 1.0, to: 1.15 }, pan: { from: [0,0], to: [60,-80] } }
*/
function prepShot(src, dest, { w, h, fps, dur, color, keyframes }) {
const hasZoom = keyframes?.zoom;
const hasPan = keyframes?.pan;
const hasRotate= keyframes?.rotate;
// ── 构建 filter 链 ──
const vfParts = [];
if (hasZoom || hasPan) {
// zoompan 同时处理缩放+平移 — 替代 scale+pad
vfParts.push(buildZoomPan({ w, h, fps, dur, keyframes }));
} else {
// 无动画: 固定缩放
vfParts.push(`scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2`);
}
// 调色
const eq = [];
if (color?.brightness) eq.push(`brightness=${color.brightness}`);
if (color?.contrast) eq.push(`contrast=${color.contrast}`);
if (color?.saturation) eq.push(`saturation=${color.saturation}`);
if (eq.length) vfParts.push(`eq=${eq.join(':')}`);
// 旋转
if (hasRotate) {
const angle = keyframes.rotate.to != null ? keyframes.rotate.to : keyframes.rotate;
vfParts.push(`rotate=${angle}*PI/180:c=none:ow=rotw(${angle}*PI/180):oh=roth(${angle}*PI/180)`);
}
const vf = vfParts.join(',');
const fpsFlag = fps ? `-r ${fps}` : '';
execSync(
`ffmpeg -y -i "${src}" -t ${dur} ${fpsFlag} ` +
`-vf "${vf}" -c:v libx264 -preset ultrafast -crf 18 -an "${dest}" 2>/dev/null`,
{ timeout: 45000 }
);
if (!fs.existsSync(dest)) throw new Error(`预处理失败: ${path.basename(src)}`);
}
/**
* 构建 zoompan filter 表达式
*
* zoompan 原理:
* z = 缩放倍率 (0~10)
* d = 1 (逐帧处理)
* x,y= 画面位移 (相对于输出尺寸的左上角偏移)
* s = 输出分辨率
*
* on变量 = 当前处理的帧序号 (从1开始)
* 首帧: if(eq(on,1), start_val, ...)
* 后续: 在上一帧基础上增量
*/
function buildZoomPan({ w, h, fps, dur, keyframes }) {
const frames = Math.round(dur * fps) || 30; // 总帧数
const zf = keyframes.zoom?.from ?? 1.0;
const zt = keyframes.zoom?.to ?? zf;
const zStep = (zt - zf) / frames;
const panFromX = keyframes.pan?.from?.[0] ?? 0;
const panFromY = keyframes.pan?.from?.[1] ?? 0;
const panToX = keyframes.pan?.to?.[0] ?? panFromX;
const panToY = keyframes.pan?.to?.[1] ?? panFromY;
const pxStep = (panToX - panFromX) / frames;
const pyStep = (panToY - panFromY) / frames;
// zoom: 首帧=zf, 否则=zoom+zStep (基于表达式中的zoom变量累加)
// pan: 首帧=偏移量, 逐帧累加
const zExpr = `if(eq(on,1),${zf},zoom+${zStep.toFixed(8)})`;
const xExpr = `iw/2-(iw/zoom/2)+if(eq(on,1),${panFromX},${panFromX}+on*${pxStep.toFixed(4)})`;
const yExpr = `ih/2-(ih/zoom/2)+if(eq(on,1),${panFromY},${panFromY}+on*${pyStep.toFixed(4)})`;
return `zoompan=z='${zExpr}':d=1:x='${xExpr}':y='${yExpr}':s=${w}x${h}`;
}
// ==================== Step 2: 视频拼接 ====================
function composeTimeline(shots, output, fps) {
if (shots.length === 1) {
fs.copyFileSync(shots[0].file, output);
return;
}
composeXFadeChain(shots, output, fps);
}
function composeXFadeChain(shots, output, fps) {
// 用 concat demuxer + xfade filter 链式拼接
// 每镜之间的过渡由 transition 决定
let filter = '';
let prev = '0:v';
let accumDur = shots[0].dur;
const inputs = shots.map((s, i) => `-i "${s.file}"`).join(' ');
for (let i = 1; i < shots.length; i++) {
const fadeDur = shots[i].transition === 'cut' ? 0.1 : 0.3;
const offset = (accumDur - fadeDur).toFixed(2);
const label = i < shots.length - 1 ? `v${i}` : 'vout';
filter += `[${prev}][${i}:v]xfade=transition=fade:duration=${fadeDur}:offset=${offset}[${label}];\n`;
prev = label;
accumDur += shots[i].dur - fadeDur;
}
execSync(
`ffmpeg -y ${inputs} -filter_complex "${filter.trim()}" ` +
`-map "[${prev}]" -r ${fps || 24} -c:v libx264 -preset fast -crf 23 -an "${output}" 2>/dev/null`,
{ timeout: 120000 }
);
}
// ==================== Step 3: 音频混合 ====================
function mixAudioTrack({ voice, bgm, sfx, bgmVolume, voiceVolume }, output, duration) {
// 构建 filter_complex: 多输入混合 + 音量控制
const inputs = [];
const filters = [];
let streamIdx = 0;
const mixInputs = [];
// 配音轨
if (voice && fs.existsSync(voice)) {
inputs.push(`-i "${voice}"`);
const vol = voiceVolume != null ? voiceVolume : 1.0;
const padDur = duration > 0 ? `,adelay=0|0,apad=pad_dur=${duration}` : '';
filters.push(`[${streamIdx}:a]volume=${vol}${padDur}[vce];`);
mixInputs.push('[vce]');
streamIdx++;
}
// BGM轨
if (bgm && fs.existsSync(bgm)) {
inputs.push(`-i "${bgm}"`);
const vol = bgmVolume != null ? bgmVolume : 0.3;
const padDur = duration > 0 ? `,adelay=0|0,apad=pad_dur=${duration}` : '';
filters.push(`[${streamIdx}:a]volume=${vol}${padDur}[bgm];`);
mixInputs.push('[bgm]');
streamIdx++;
}
// 音效轨
if (sfx && sfx.length > 0) {
for (const s of sfx) {
if (!fs.existsSync(s.file)) continue;
inputs.push(`-i "${s.file}"`);
const delay = (s.at || 0) * 1000; // 毫秒
const padDur = duration > 0 ? `,adelay=${delay}|${delay},apad=pad_dur=${duration}` : `,adelay=${delay}|${delay}`;
filters.push(`[${streamIdx}:a]volume=1.0${padDur}[sfx${streamIdx}];`);
mixInputs.push(`[sfx${streamIdx}]`);
streamIdx++;
}
}
if (mixInputs.length === 0) {
// 静音轨
execSync(`ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t ${duration} -c:a pcm_s16le "${output}" 2>/dev/null`);
return;
}
if (mixInputs.length === 1) {
// 只有一个输入
const filterFull = filters.join('\n').replace(/\[(vce|bgm|sfx\d+)\];/, '');
execSync(
`ffmpeg -y ${inputs.join(' ')} -filter_complex "${filterFull.trim()}" ` +
`-c:a pcm_s16le "${output}" 2>/dev/null`,
{ timeout: 60000 }
);
return;
}
// 多输入混合
const amix = mixInputs.join('');
const mixFilter = `${filters.join('\n')}${amix}amix=inputs=${mixInputs.length}:duration=longest:dropout_transition=2[amix]`;
execSync(
`ffmpeg -y ${inputs.join(' ')} -filter_complex "${mixFilter}" ` +
`-map "[amix]" -c:a pcm_s16le "${output}" 2>/dev/null`,
{ timeout: 60000 }
);
}
function mergeAudio(videoFile, audioFile, output) {
execSync(
`ffmpeg -y -i "${videoFile}" -i "${audioFile}" ` +
`-c:v copy -c:a aac -b:a 192k -shortest -map 0:v:0 -map 1:a:0 "${output}" 2>/dev/null`,
{ timeout: 60000 }
);
}
// ==================== Step 4: 字幕烧录 ====================
function burnSubtitles(videoFile, srtFile, output) {
// SRT 内嵌到视频帧
// 用 subtitles filter: 中文兼容 · 字体回退
execSync(
`ffmpeg -y -i "${videoFile}" -vf ` +
`"subtitles='${srtFile}':force_style='FontName=PingFang SC,Fontsize=28,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=1.5,Shadow=1,MarginV=30'" ` +
`-c:v libx264 -preset fast -crf 23 -c:a copy "${output}" 2>/dev/null`,
{ timeout: 120000 }
);
}
// ==================== Step 5: 最终编码 ====================
function finalEncode(src, dest) {
execSync(
`ffmpeg -y -i "${src}" ` +
`-c:v libx264 -preset medium -crf 21 ` +
`-c:a aac -b:a 192k -movflags +faststart "${dest}" 2>/dev/null`,
{ timeout: 60000 }
);
}
// ==================== 工具 ====================
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()) || 4.0;
} catch (_) { return 4.0; }
}
function fmtSize(file) {
try {
const s = fs.statSync(file).size;
return s < 1048576 ? `${(s / 1024).toFixed(0)}KB` : `${(s / 1048576).toFixed(1)}MB`;
} catch (_) { return '?'; }
}
// ==================== 子工具:独立字幕生成 ====================
/**
* 从文本生成 SRT 字幕(简单场景:每句均匀分配)
* @param {string[]} lines - 字幕文本行
* @param {number} duration - 视频总时长(秒)
*/
function generateSRT(lines, duration) {
const perLine = duration / lines.length;
let srt = '';
for (let i = 0; i < lines.length; i++) {
const start = i * perLine;
const end = start + perLine;
srt += `${i + 1}\n`;
srt += `${fmtTime(start)} --> ${fmtTime(end)}\n`;
srt += `${lines[i]}\n\n`;
}
return srt;
}
function fmtTime(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = (sec % 60).toFixed(3);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(6, '0')}`.replace('.', ',');
}
// ==================== 导出 ====================
module.exports = { edit, composeTimeline, mixAudioTrack, burnSubtitles, generateSRT, probeSec };