From 20b183c52ffd4dd041196c9ce80aeb4ea719ee1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sun, 21 Jun 2026 14:44:42 +0800 Subject: [PATCH] =?UTF-8?q?D136+:=20video-editor.js=20v2.0=20=E2=80=94=20?= =?UTF-8?q?=E4=B8=80=E7=AB=99=E5=BC=8F=E5=89=AA=E8=BE=91=E5=BC=95=E6=93=8E?= =?UTF-8?q?=20=E2=80=94=20=E5=A4=9A=E8=BD=A8=E5=90=88=E6=88=90(=E8=A7=86?= =?UTF-8?q?=E9=A2=91+=E9=85=8D=E9=9F=B3+BGM+=E9=9F=B3=E6=95=88+=E5=AD=97?= =?UTF-8?q?=E5=B9=95+=E8=B0=83=E8=89=B2)=20=E2=80=94=20=E5=85=A8ffmpeg?= =?UTF-8?q?=E7=BA=AF=E4=BB=A3=E7=A0=81=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-ai-system/engines/video-editor.js | 319 ++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 video-ai-system/engines/video-editor.js diff --git a/video-ai-system/engines/video-editor.js b/video-ai-system/engines/video-editor.js new file mode 100644 index 0000000..fe809cd --- /dev/null +++ b/video-ai-system/engines/video-editor.js @@ -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 };