Restore original zoom motion and shift timing 300ms earlier

This commit is contained in:
webadderall
2026-09-23 11:48:31 +10:00
parent 7c4679aa44
commit 93f155395b
6 changed files with 157 additions and 200 deletions
+16 -12
View File
@@ -107,7 +107,7 @@ import {
getZoomSpringConfig,
resetSpringState,
type SpringState,
stepBoundedZoomSpring,
stepSpringValue,
} from "./videoPlayback/motionSmoothing";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { supportsPreviewPlaybackRate } from "./videoPlayback/playbackRate";
@@ -2094,20 +2094,24 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
let appliedY: number;
if (motionMode === "spring" && contentAdvanced) {
const bounded = stepBoundedZoomSpring(
{
scale: springScaleRef.current,
x: springXRef.current,
y: springYRef.current,
},
projectedTransform,
appliedScale = stepSpringValue(
springScaleRef.current,
projectedTransform.scale,
deltaMs,
zoomSpringConfig,
);
appliedX = stepSpringValue(
springXRef.current,
projectedTransform.x,
deltaMs,
zoomSpringConfig,
);
appliedY = stepSpringValue(
springYRef.current,
projectedTransform.y,
deltaMs,
zoomSpringConfig,
target.progress < 1,
);
appliedScale = bounded.scale;
appliedX = bounded.x;
appliedY = bounded.y;
} else if (motionMode === "snap") {
// Timeline seeks and classic mode intentionally evaluate the exact target.
appliedScale = projectedTransform.scale;
@@ -317,26 +317,3 @@ export function getZoomSpringConfig(
tuning,
);
}
/** Keep spring lag inside the zoom envelope, including an exact neutral end frame. */
export function stepBoundedZoomSpring(
springs: { scale: SpringState; x: SpringState; y: SpringState },
target: { scale: number; x: number; y: number },
deltaMs: number,
config: SpringConfig,
envelopeActive = true,
) {
const scale = stepSpringValue(springs.scale, target.scale, deltaMs, config);
const x = stepSpringValue(springs.x, target.x, deltaMs, config);
const y = stepSpringValue(springs.y, target.y, deltaMs, config);
const boundedScale = Math.max(1, envelopeActive ? Math.min(target.scale, scale) : scale);
if (target.scale <= 1 || boundedScale !== scale) {
const ratio = scale > 1 ? (boundedScale - 1) / (scale - 1) : 0;
const result = { scale: boundedScale, x: x * ratio, y: y * ratio };
resetSpringState(springs.scale, result.scale);
resetSpringState(springs.x, result.x);
resetSpringState(springs.y, result.y);
return result;
}
return { scale, x, y };
}
@@ -10,7 +10,6 @@ import {
type SpringConfig,
type SpringState,
stepSpringValue,
stepBoundedZoomSpring,
} from "./motionSmoothing";
import { computeRegionStrength, findDominantRegion } from "./zoomRegionUtils";
@@ -314,24 +313,29 @@ describe("computeRegionStrength", () => {
it("reaches full strength during the hold phase", () => {
// Mid-region: after zoom-in completes, before zoom-out starts
expect(computeRegionStrength(region, 3600)).toBe(1);
expect(computeRegionStrength(region, 3500)).toBe(1);
});
it("rises smoothly during zoom-in", () => {
const s = computeRegionStrength(region, region.startMs + 200);
// Zoom-in transitions from leadInStart .. zoomInEnd
// zoomInEnd = startMs + 500, leadInStart = zoomInEnd - 1500 = startMs - 1000
// So at startMs the transition is partially done
const s = computeRegionStrength(region, region.startMs);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
});
it("falls smoothly during zoom-out", () => {
const s = computeRegionStrength(region, region.endMs - 200);
// Sample the original zoom-out ramp shifted 300ms earlier.
const zoomOutStart = region.endMs - 150;
const s = computeRegionStrength(region, zoomOutStart + 700 - 300);
expect(s).toBeGreaterThan(0);
expect(s).toBeLessThan(1);
});
it("shifts zoom timing when custom durations are provided", () => {
const defaultStrength = computeRegionStrength(region, region.startMs + 200);
const fasterStrength = computeRegionStrength(region, region.startMs + 200, {
const defaultStrength = computeRegionStrength(region, region.startMs);
const fasterStrength = computeRegionStrength(region, region.startMs, {
zoomInDurationMs: 300,
zoomOutDurationMs: 300,
});
@@ -356,7 +360,7 @@ describe("findDominantRegion", () => {
const regions: ZoomRegion[] = [
{ id: "a", startMs: 1000, endMs: 4000, depth: 2, focus: { cx: 0.3, cy: 0.3 } },
];
const result = findDominantRegion(regions, 2550);
const result = findDominantRegion(regions, 2500);
expect(result.region).not.toBeNull();
expect(result.region!.id).toBe("a");
expect(result.strength).toBe(1);
@@ -389,7 +393,7 @@ describe("findDominantRegion", () => {
{ id: "b", startMs: 3500, endMs: 6000, depth: 3, focus: { cx: 0.8, cy: 0.8 } },
];
const result = findDominantRegion(regions, 2900, { connectZooms: true });
const result = findDominantRegion(regions, 2800, { connectZooms: true });
expect(result.transition).toBeNull();
expect(result.region?.id).toBe("a");
expect(result.strength).toBeGreaterThan(0);
@@ -424,7 +428,7 @@ describe("findDominantRegion", () => {
{ id: "b", startMs: 4300, endMs: 7000, depth: 3, focus: { cx: 0.7, cy: 0.7 } },
];
// Between connected blocks, the next focus remains active.
// After transition end (3000-100+1000=3900) but before b starts (4300)
const result = findDominantRegion(regions, 4250, { connectZooms: true });
expect(result.strength).toBe(1);
expect(result.region).not.toBeNull();
@@ -548,88 +552,3 @@ describe("spring damping regimes", () => {
expect(s.value).toBeCloseTo(1, 2);
});
});
describe("zoom block boundaries", () => {
it.each([100, 500, 3000])("keeps a %dms block and its springs inside its bounds", (length) => {
const region: ZoomRegion = {
id: "bounded",
startMs: 1000,
endMs: 1000 + length,
depth: 2,
focus: { cx: 0.5, cy: 0.5 },
};
const springs = {
scale: createSpringState(1),
x: createSpringState(),
y: createSpringState(),
};
const config = getZoomSpringConfig(0.5);
for (let time = 900; time <= region.endMs + 100; time += 10) {
const strength = computeRegionStrength(region, time, {
zoomInDurationMs: 1500,
zoomOutDurationMs: 1000,
});
const frame = stepBoundedZoomSpring(
springs,
{ scale: 1 + strength, x: -100 * strength, y: -50 * strength },
10,
config,
);
expect(frame.scale).toBeGreaterThanOrEqual(1);
expect(frame.scale).toBeLessThanOrEqual(1 + strength);
if (time <= region.startMs || time >= region.endMs) {
expect(strength).toBe(0);
expect(frame.scale).toBe(1);
expect(Math.abs(frame.x) + Math.abs(frame.y)).toBe(0);
}
}
const peak = region.startMs + length * 0.6;
expect(
computeRegionStrength(region, peak, {
zoomInDurationMs: 6000,
zoomOutDurationMs: 4000,
}),
).toBeCloseTo(1);
expect(
computeRegionStrength(region, peak - 0.001, {
zoomInDurationMs: 6000,
zoomOutDurationMs: 4000,
}),
).toBeCloseTo(1);
expect(
computeRegionStrength(region, peak + 0.001, {
zoomInDurationMs: 6000,
zoomOutDurationMs: 4000,
}),
).toBeCloseTo(1);
});
it("bounds a connected chain to its first start and last end", () => {
const regions: ZoomRegion[] = [
{ id: "a", startMs: 1000, endMs: 1100, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
{ id: "b", startMs: 1400, endMs: 1500, depth: 2, focus: { cx: 0.5, cy: 0.5 } },
];
for (const time of [999, 1000, 1500, 1501])
expect(findDominantRegion(regions, time, { connectZooms: true }).strength).toBe(0);
expect(findDominantRegion(regions, 1100, { connectZooms: true }).strength).toBe(1);
expect(findDominantRegion(regions, 1250, { connectZooms: false }).strength).toBe(0);
});
});
it("preserves spring interpolation when connected zooms change depth at full strength", () => {
const springs = {
scale: createSpringState(3),
x: createSpringState(-200),
y: createSpringState(-100),
};
for (const state of Object.values(springs)) state.initialized = true;
const frame = stepBoundedZoomSpring(
springs,
{ scale: 2, x: -100, y: -50 },
16,
getZoomSpringConfig(0.5),
false,
);
expect(frame.scale).toBeGreaterThan(2);
expect(frame.scale).toBeLessThan(3);
});
@@ -1,10 +1,18 @@
import type { ZoomFocus, ZoomRegion } from "../types";
import { ZOOM_DEPTH_SCALES } from "../types";
import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
import {
TRANSITION_WINDOW_MS,
ZOOM_IN_TRANSITION_WINDOW_MS,
ZOOM_OUT_EARLY_START_MS,
} from "./constants";
import { clampFocusToScale } from "./focusUtils";
import { clamp01, easeOutZoom } from "./mathUtils";
const CHAINED_ZOOM_PAN_GAP_MS = 1350;
const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
const ZOOM_IN_OVERLAP_MS = 1000;
// Shift the original animation timing 300ms earlier without changing its motion.
const ZOOM_ANIMATION_LEAD_MS = -100;
type DominantRegionOptions = {
connectZooms?: boolean;
@@ -16,6 +24,7 @@ type ConnectedRegionPair = {
currentRegion: ZoomRegion;
nextRegion: ZoomRegion;
transitionStart: number;
transitionEnd: number;
};
type ConnectedPanTransition = {
@@ -33,18 +42,34 @@ export function computeRegionStrength(
) {
const zoomInDurationMs = Math.max(1, options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS);
const zoomOutDurationMs = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS);
const length = region.endMs - region.startMs;
if (length <= 0 || timeMs <= region.startMs || timeMs >= region.endMs) return 0;
const adjustedTimeMs = timeMs - ZOOM_ANIMATION_LEAD_MS;
const leadInStart = region.startMs + ZOOM_IN_OVERLAP_MS - ZOOM_IN_TRANSITION_WINDOW_MS;
let zoomOutStart = region.endMs - ZOOM_OUT_EARLY_START_MS;
let zoomInEnd = leadInStart + zoomInDurationMs;
// Short blocks proportionally compress both ramps, with no discontinuity at
// their meeting point. Every duration setting stays inside the block.
const fit = Math.min(1, length / (zoomInDurationMs + zoomOutDurationMs));
const inDuration = zoomInDurationMs * fit;
const outDuration = zoomOutDurationMs * fit;
if (timeMs < region.startMs + inDuration) {
return easeOutZoom((timeMs - region.startMs) / inDuration);
if (zoomInEnd > zoomOutStart) {
const midpoint = (zoomInEnd + zoomOutStart) / 2;
zoomInEnd = midpoint;
zoomOutStart = midpoint;
}
return 1 - easeOutZoom(clamp01((timeMs - (region.endMs - outDuration)) / outDuration));
const leadOutEnd = zoomOutStart + zoomOutDurationMs;
if (adjustedTimeMs < leadInStart || adjustedTimeMs > leadOutEnd) {
return 0;
}
if (adjustedTimeMs < zoomInEnd) {
const progress = (adjustedTimeMs - leadInStart) / zoomInDurationMs;
return easeOutZoom(progress);
}
if (adjustedTimeMs <= zoomOutStart) {
return 1;
}
const progress = clamp01((adjustedTimeMs - zoomOutStart) / zoomOutDurationMs);
return 1 - easeOutZoom(progress);
}
function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
@@ -60,14 +85,16 @@ function getConnectedRegionPairs(regions: ZoomRegion[]) {
const nextRegion = sortedRegions[index + 1];
const gapMs = nextRegion.startMs - currentRegion.endMs;
if (gapMs < 0 || gapMs > CHAINED_ZOOM_PAN_GAP_MS) {
if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) {
continue;
}
pairs.push({
currentRegion,
nextRegion,
transitionStart: currentRegion.endMs,
transitionStart: currentRegion.endMs + ZOOM_ANIMATION_LEAD_MS,
transitionEnd:
currentRegion.endMs + ZOOM_ANIMATION_LEAD_MS + CONNECTED_ZOOM_PAN_DURATION_MS,
});
}
@@ -83,30 +110,33 @@ function getActiveRegion(
const activeRegions = regions
.map((region) => {
const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id);
if (outgoingPair) {
if (timeMs >= outgoingPair.transitionStart) {
return { region, strength: 0 };
}
const zoomOutStart =
outgoingPair.currentRegion.endMs -
ZOOM_OUT_EARLY_START_MS +
ZOOM_ANIMATION_LEAD_MS;
if (timeMs >= zoomOutStart) {
return { region, strength: 1 };
}
}
const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id);
const start = incomingPair?.transitionStart ?? region.startMs;
if (timeMs < start || (!incomingPair && timeMs === start) || timeMs >= region.endMs)
return { region, strength: 0 };
if (incomingPair || outgoingPair) {
const inDuration = Math.max(
1,
options.zoomInDurationMs ?? ZOOM_IN_TRANSITION_WINDOW_MS,
);
const outDuration = Math.max(1, options.zoomOutDurationMs ?? TRANSITION_WINDOW_MS);
const total = (incomingPair ? 0 : inDuration) + (outgoingPair ? 0 : outDuration);
const fit = total > 0 ? Math.min(1, (region.endMs - start) / total) : 1;
const strengthIn = incomingPair
? 1
: easeOutZoom(clamp01((timeMs - start) / (inDuration * fit)));
const strengthOut = outgoingPair
? 1
: 1 -
easeOutZoom(
clamp01(
(timeMs - (region.endMs - outDuration * fit)) / (outDuration * fit),
),
);
return { region, strength: Math.min(strengthIn, strengthOut) };
if (incomingPair) {
if (timeMs < incomingPair.transitionStart) {
return { region, strength: 0 };
}
const nextRegionZoomOutStart =
incomingPair.nextRegion.endMs -
ZOOM_OUT_EARLY_START_MS +
ZOOM_ANIMATION_LEAD_MS;
if (timeMs < nextRegionZoomOutStart) {
return { region, strength: 1 };
}
}
return { region, strength: computeRegionStrength(region, timeMs, options) };
@@ -137,6 +167,24 @@ function getActiveRegion(
};
}
function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
for (const pair of connectedPairs) {
if (timeMs >= pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
return {
region: {
...pair.nextRegion,
focus: getResolvedFocus(pair.nextRegion, nextScale),
},
strength: 1,
blendedScale: null,
};
}
}
return null;
}
export function findDominantRegion(
regions: ZoomRegion[],
timeMs: number,
@@ -149,6 +197,13 @@ export function findDominantRegion(
} {
const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
if (options.connectZooms) {
const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
if (connectedHold) {
return { ...connectedHold, transition: null };
}
}
const activeRegion = getActiveRegion(regions, timeMs, connectedPairs, options);
return activeRegion
? { ...activeRegion, transition: null }
+19 -17
View File
@@ -40,14 +40,11 @@ import {
getZoomSpringConfig,
resetSpringState,
type SpringState,
stepBoundedZoomSpring,
stepSpringValue,
} from "@/components/video-editor/videoPlayback/motionSmoothing";
import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects";
import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion";
import {
getWebcamMediaTargetTimeSeconds,
isWebcamVisibleAtSourceTime,
} from "@/components/video-editor/videoPlayback/webcamSync";
import { getWebcamMediaTargetTimeSeconds, isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync";
import {
applyZoomTransform,
computeZoomTransform,
@@ -79,6 +76,7 @@ import { renderCaptions } from "./captionRenderer";
import { ForwardFrameSource } from "./forwardFrameSource";
import { resolveMediaElementSource } from "./localMediaSource";
interface FrameRenderConfig {
timelineEffects?: boolean;
width: number;
@@ -1651,16 +1649,24 @@ export class FrameRenderer {
resetSpringState(this.springX, state.x);
resetSpringState(this.springY, state.y);
} else {
const bounded = stepBoundedZoomSpring(
{ scale: this.springScale, x: this.springX, y: this.springY },
projectedTransform,
state.appliedScale = stepSpringValue(
this.springScale,
projectedTransform.scale,
deltaMs,
zoomSpringConfig,
);
state.x = stepSpringValue(
this.springX,
projectedTransform.x,
deltaMs,
zoomSpringConfig,
);
state.y = stepSpringValue(
this.springY,
projectedTransform.y,
deltaMs,
zoomSpringConfig,
target.progress < 1,
);
state.appliedScale = bounded.scale;
state.x = bounded.x;
state.y = bounded.y;
}
return Math.max(
@@ -1733,11 +1739,7 @@ export class FrameRenderer {
const webcam = this.config.webcam;
const webcamDecodedFrame = this.webcamDecodedFrame;
const webcamVideo = this.webcamVideoElement;
if (
!webcam?.enabled ||
!isWebcamVisibleAtSourceTime(webcam, this.currentVideoTime) ||
(!webcamDecodedFrame && !webcamVideo)
) {
if (!webcam?.enabled || !isWebcamVisibleAtSourceTime(webcam, this.currentVideoTime) || (!webcamDecodedFrame && !webcamVideo)) {
return;
}
+18 -18
View File
@@ -49,14 +49,11 @@ import {
getZoomSpringConfig,
resetSpringState,
type SpringState,
stepBoundedZoomSpring,
stepSpringValue,
} from "@/components/video-editor/videoPlayback/motionSmoothing";
import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects";
import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion";
import {
getWebcamMediaTargetTimeSeconds,
isWebcamVisibleAtSourceTime,
} from "@/components/video-editor/videoPlayback/webcamSync";
import { getWebcamMediaTargetTimeSeconds, isWebcamVisibleAtSourceTime } from "@/components/video-editor/videoPlayback/webcamSync";
import {
applyZoomTransform,
computeZoomTransform,
@@ -2728,12 +2725,7 @@ export class FrameRenderer {
private updateWebcamOverlay(referenceTimeSeconds = this.currentVideoTime): void {
const webcam = this.config.webcam;
if (
!webcam?.enabled ||
!isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) ||
!this.webcamRootContainer ||
!this.webcamMaskGraphics
) {
if (!webcam?.enabled || !isWebcamVisibleAtSourceTime(webcam, referenceTimeSeconds) || !this.webcamRootContainer || !this.webcamMaskGraphics) {
if (this.webcamRootContainer) {
this.webcamRootContainer.visible = false;
}
@@ -3152,16 +3144,24 @@ export class FrameRenderer {
resetSpringState(this.springX, state.x);
resetSpringState(this.springY, state.y);
} else {
const bounded = stepBoundedZoomSpring(
{ scale: this.springScale, x: this.springX, y: this.springY },
projectedTransform,
state.appliedScale = stepSpringValue(
this.springScale,
projectedTransform.scale,
deltaMs,
zoomSpringConfig,
);
state.x = stepSpringValue(
this.springX,
projectedTransform.x,
deltaMs,
zoomSpringConfig,
);
state.y = stepSpringValue(
this.springY,
projectedTransform.y,
deltaMs,
zoomSpringConfig,
target.progress < 1,
);
state.appliedScale = bounded.scale;
state.x = bounded.x;
state.y = bounded.y;
}
return Math.max(