diff --git a/video-ai-system/engines/video-editor.js b/video-ai-system/engines/video-editor.js index fe809cd..832849c 100644 --- a/video-ai-system/engines/video-editor.js +++ b/video-ai-system/engines/video-editor.js @@ -14,7 +14,8 @@ * const { edit } = require('./video-editor'); * await edit({ * timeline: [ - * { shot: 'shot01.mp4', trim: 3.5, transition: 'fade' }, + * { 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: { @@ -71,7 +72,7 @@ async function edit({ 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 }); + 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`); } @@ -118,18 +119,91 @@ async function edit({ // ==================== Step 1: 镜头预处理 ==================== -function prepShot(src, dest, { w, h, fps, dur, color }) { +/** + * 预处理单镜:统一分辨率/帧率/调色 → 关键帧动画 → 输出标准化片段 + * + * 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}`); - const eqStr = eq.length ? `,eq=${eq.join(':')}` : ''; + 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} -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 } + `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: 视频拼接 ====================