/** * 光湖视频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 };