冰朔 20b183c52f
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled
D136+: video-editor.js v2.0 — 一站式剪辑引擎 — 多轨合成(视频+配音+BGM+音效+字幕+调色) — 全ffmpeg纯代码驱动
2026-06-21 14:44:42 +08:00

320 lines
10 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系统 · 视频剪辑引擎 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' },
* { 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 });
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: 镜头预处理 ====================
function prepShot(src, dest, { w, h, fps, dur, color }) {
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}`);
const eqStr = eq.length ? `,eq=${eq.join(':')}` : '';
execSync(
`ffmpeg -y -i "${src}" -t ${dur} -r ${fps} ` +
`-vf "scale=${w}:${h}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2${eqStr}" ` +
`-c:v libx264 -preset ultrafast -crf 18 -an "${dest}" 2>/dev/null`,
{ timeout: 30000 }
);
}
// ==================== 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 : 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 };