D136+: video-editor keyframe动画 — zoompan缩放+位移+旋转 — 每镜独立控制推拉摇移
This commit is contained in:
parent
20b183c52f
commit
8a73691413
@ -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: 视频拼接 ====================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user