D136+: video-editor keyframe动画 — zoompan缩放+位移+旋转 — 每镜独立控制推拉摇移
Some checks failed
自动更新代码和重启 / update-and-restart (push) Has been cancelled
CI检查 + 自动部署 / check (push) Has been cancelled
CI检查 + 自动部署 / deploy (push) Has been cancelled

This commit is contained in:
冰朔 2026-06-21 14:48:46 +08:00
parent 20b183c52f
commit 8a73691413

View File

@ -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: 视频拼接 ====================