From 93f155395b867e7a57592e122a2e8eafad48deb5 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:48:31 +1000 Subject: [PATCH] Restore original zoom motion and shift timing 300ms earlier --- src/components/video-editor/VideoPlayback.tsx | 28 ++-- .../videoPlayback/motionSmoothing.ts | 23 ---- .../videoPlayback/zoomAnimation.test.ts | 107 ++------------- .../videoPlayback/zoomRegionUtils.ts | 127 +++++++++++++----- src/lib/exporter/frameRenderer.ts | 36 ++--- src/lib/exporter/modernFrameRenderer.ts | 36 ++--- 6 files changed, 157 insertions(+), 200 deletions(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index c4ec2c43..90a8533a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( 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; diff --git a/src/components/video-editor/videoPlayback/motionSmoothing.ts b/src/components/video-editor/videoPlayback/motionSmoothing.ts index cd6c7456..c4ec3852 100644 --- a/src/components/video-editor/videoPlayback/motionSmoothing.ts +++ b/src/components/video-editor/videoPlayback/motionSmoothing.ts @@ -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 }; -} diff --git a/src/components/video-editor/videoPlayback/zoomAnimation.test.ts b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts index 73748cf9..e57f1322 100644 --- a/src/components/video-editor/videoPlayback/zoomAnimation.test.ts +++ b/src/components/video-editor/videoPlayback/zoomAnimation.test.ts @@ -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); -}); diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 5f7aa384..9076b383 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -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 } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index af662e52..d42f6a69 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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; } diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index a73a6122..c66c18d7 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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(