D136+: video-editor.js v2.0 — 一站式剪辑引擎 — 多轨合成(视频+配音+BGM+音效+字幕+调色) — 全ffmpeg纯代码驱动
This commit is contained in:
parent
6b543d831c
commit
20b183c52f
319
video-ai-system/engines/video-editor.js
Normal file
319
video-ai-system/engines/video-editor.js
Normal file
@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 光湖视频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 };
|
||||
Loading…
x
Reference in New Issue
Block a user