diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index aa6078f9a..4bdd44eca 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -40,6 +40,7 @@ function clampPercent(value: number) { return Math.min(100, Math.max(0, value)); } +/** Render an annotation in preview space with editor drag and resize controls. */ export function AnnotationOverlay({ annotation, isSelected, @@ -120,14 +121,14 @@ export function AnnotationOverlay({ ? "flex-end" : "center", alignItems: "center", - padding: `${8 * sceneTransform.scale}px`, + padding: `${8 * sizeScale}px`, }} > @@ -174,7 +175,10 @@ export function AnnotationOverlay({ } return ( -
+
{renderArrow()}
); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index e3e221d07..87e36a853 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -80,20 +80,20 @@ import { type SpeedRegion, type TrimRegion, type WebcamOverlaySettings, - ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus, type ZoomMotionBlurTuning, type ZoomRegion, type ZoomTransitionEasing, } from "./types"; +import { + isAnnotationActiveAtTime, + shouldClearSelectedAnnotation, +} from "./videoPlayback/annotationVisibility"; import { DEFAULT_FOCUS } from "./videoPlayback/constants"; import { type CursorFollowCameraState, - computeCursorFollowFocus, createCursorFollowCameraState, - resetCursorFollowCamera, - SNAP_TO_EDGES_RATIO_AUTO, } from "./videoPlayback/cursorFollowCamera"; import { DEFAULT_CURSOR_CONFIG, @@ -112,13 +112,18 @@ import { } from "./videoPlayback/motionSmoothing"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { PreviewVideoSource } from "./videoPlayback/previewVideoSource"; +import { getSceneEffectMetrics } from "./videoPlayback/sceneEffects"; +import { + resolvePreviewMotionMode, + resolveSceneZoomTarget, + shouldComposePreviewFrame, +} from "./videoPlayback/sceneMotion"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { getWebcamMediaTargetTimeSeconds, isWebcamMediaSynchronized, shouldSeekWebcamMedia, } from "./videoPlayback/webcamSync"; -import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; import { applyZoomTransform, computeZoomTransform, @@ -406,6 +411,7 @@ const VideoPlayback = forwardRef( const [pixiRendererBackend, setPixiRendererBackend] = useState( null, ); + const [previewViewportWidth, setPreviewViewportWidth] = useState(640); const [annotationSceneTransform, setAnnotationSceneTransform] = useState({ scale: 1, @@ -467,6 +473,7 @@ const VideoPlayback = forwardRef( const isPlayingRef = useRef(isPlaying); const suspendRenderingRef = useRef(suspendRendering); const isSeekingRef = useRef(false); + const shouldSnapPausedFrameRef = useRef(false); const allowPlaybackRef = useRef(false); const lockedVideoDimensionsRef = useRef<{ width: number; @@ -516,12 +523,18 @@ const VideoPlayback = forwardRef( const springScaleRef = useRef(createSpringState(1)); const springXRef = useRef(createSpringState(0)); const springYRef = useRef(createSpringState(0)); - const lastTickTimeRef = useRef(null); + const lastRenderedContentTimeRef = useRef(null); const zoomSmoothnessRef = useRef(zoomSmoothness); const zoomClassicModeRef = useRef(zoomClassicMode); const cursorFollowCameraRef = useRef( createCursorFollowCameraState(), ); + /** Requests one exact composition after an output-affecting edit while paused. */ + const requestPausedFrameRefresh = useCallback(() => { + if (!isPlayingRef.current) { + shouldSnapPausedFrameRef.current = true; + } + }, []); const initializePixiRenderer = useCallback( async ( @@ -630,7 +643,7 @@ const VideoPlayback = forwardRef( return null; } - measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; + measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${autoCaptionSettings.fontFamily || getDefaultCaptionFontFamily()}`; return buildActiveCaptionLayout({ cues: autoCaptions, @@ -665,7 +678,7 @@ const VideoPlayback = forwardRef( return null; } - measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; + measurementContext.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${autoCaptionSettings.fontFamily || getDefaultCaptionFontFamily()}`; const measuredWidth = Math.max( ...captionEditSession.draft .split(/\r?\n/) @@ -1027,6 +1040,11 @@ const VideoPlayback = forwardRef( if (result) { stageSizeRef.current = result.stageSize; + setPreviewViewportWidth((current) => + Math.abs(current - result.stageSize.width) < 0.5 + ? current + : result.stageSize.width, + ); syncPreviewMotionBlurQuality(); videoSizeRef.current = result.videoSize; baseScaleRef.current = result.baseScale; @@ -1240,22 +1258,31 @@ const VideoPlayback = forwardRef( useEffect(() => { zoomRegionsRef.current = zoomRegions; - }, [zoomRegions]); + requestPausedFrameRefresh(); + }, [zoomRegions, requestPausedFrameRefresh]); useEffect(() => { selectedZoomIdRef.current = selectedZoomId; }, [selectedZoomId]); useEffect(() => { - isPlayingRef.current = isPlaying; - // Snap springs to current position when pausing so scrubbing is instant - if (!isPlaying) { - resetSpringState(springScaleRef.current); - resetSpringState(springXRef.current); - resetSpringState(springYRef.current); - resetCursorFollowCamera(cursorFollowCameraRef.current); - lastTickTimeRef.current = null; + if (!selectedAnnotationId || !onSelectAnnotation) { + return; + } + + if ( + shouldClearSelectedAnnotation( + annotationRegions ?? [], + selectedAnnotationId, + Math.round(currentTime * 1000), + ) + ) { + onSelectAnnotation(null); } + }, [annotationRegions, currentTime, onSelectAnnotation, selectedAnnotationId]); + + useEffect(() => { + isPlayingRef.current = isPlaying; const bgVideo = bgVideoRef.current; if (bgVideo) { if (isPlaying) { @@ -1402,11 +1429,13 @@ const VideoPlayback = forwardRef( useEffect(() => { connectZoomsRef.current = connectZooms; - }, [connectZooms]); + requestPausedFrameRefresh(); + }, [connectZooms, requestPausedFrameRefresh]); useEffect(() => { zoomInDurationMsRef.current = zoomInDurationMs; - }, [zoomInDurationMs]); + requestPausedFrameRefresh(); + }, [zoomInDurationMs, requestPausedFrameRefresh]); useEffect(() => { zoomInOverlapMsRef.current = zoomInOverlapMs; @@ -1414,7 +1443,8 @@ const VideoPlayback = forwardRef( useEffect(() => { zoomOutDurationMsRef.current = zoomOutDurationMs; - }, [zoomOutDurationMs]); + requestPausedFrameRefresh(); + }, [zoomOutDurationMs, requestPausedFrameRefresh]); useEffect(() => { connectedZoomGapMsRef.current = connectedZoomGapMs; @@ -1438,11 +1468,13 @@ const VideoPlayback = forwardRef( useEffect(() => { cursorTelemetryRef.current = cursorTelemetry; - }, [cursorTelemetry]); + requestPausedFrameRefresh(); + }, [cursorTelemetry, requestPausedFrameRefresh]); useEffect(() => { showCursorRef.current = showCursor; - }, [showCursor]); + requestPausedFrameRefresh(); + }, [showCursor, requestPausedFrameRefresh]); useEffect(() => { cursorStyleRef.current = cursorStyle; @@ -1486,6 +1518,7 @@ const VideoPlayback = forwardRef( useEffect(() => { zoomMotionBlurRef.current = zoomMotionBlur; + requestPausedFrameRefresh(); const videoEffectsContainer = videoEffectsContainerRef.current; const zoomBlurFilter = zoomBlurFilterRef.current; @@ -1498,15 +1531,17 @@ const VideoPlayback = forwardRef( motionBlurStateRef.current = createMotionBlurState(); videoEffectsContainer.filters = zoomMotionBlur > 0 ? [motionBlurFilter, zoomBlurFilter] : null; - }, [zoomMotionBlur]); + }, [zoomMotionBlur, requestPausedFrameRefresh]); useEffect(() => { zoomMotionBlurTuningRef.current = zoomMotionBlurTuning; - }, [zoomMotionBlurTuning]); + requestPausedFrameRefresh(); + }, [zoomMotionBlurTuning, requestPausedFrameRefresh]); useEffect(() => { zoomClassicModeRef.current = zoomClassicMode; - }, [zoomClassicMode]); + requestPausedFrameRefresh(); + }, [zoomClassicMode, requestPausedFrameRefresh]); useEffect(() => { cursorMotionBlurRef.current = cursorMotionBlur; @@ -1919,6 +1954,8 @@ const VideoPlayback = forwardRef( video.pause(); video.currentTime = 0; allowPlaybackRef.current = false; + lastRenderedContentTimeRef.current = null; + shouldSnapPausedFrameRef.current = true; lockedVideoDimensionsRef.current = null; setVideoReady(false); if (videoReadyRafRef.current) { @@ -1976,6 +2013,7 @@ const VideoPlayback = forwardRef( createVideoEventHandlers({ video, isSeekingRef, + shouldSnapPausedFrameRef, isPlayingRef, allowPlaybackRef, currentTimeRef, @@ -2045,7 +2083,7 @@ const VideoPlayback = forwardRef( motionBlurTuning: zoomMotionBlurTuningRef.current, transformOverride: transform, motionBlurState: motionBlurStateRef.current, - frameTimeMs: performance.now(), + frameTimeMs: currentTimeRef.current, }); state.x = appliedTransform.x; @@ -2073,59 +2111,51 @@ const VideoPlayback = forwardRef( return; } - const { region, strength, blendedScale } = findDominantRegion( - zoomRegionsRef.current, - currentTimeRef.current, - { - connectZooms: connectZoomsRef.current, - zoomInDurationMs: zoomInDurationMsRef.current, - zoomOutDurationMs: zoomOutDurationMsRef.current, - }, - ); - - const defaultFocus = DEFAULT_FOCUS; - let targetScaleFactor = 1; - let targetFocus = defaultFocus; - let targetProgress = 0; - - // If a zoom is selected but video is not playing, show default unzoomed view - // (the overlay will show where the zoom will be) - const selectedId = selectedZoomIdRef.current; - const hasSelectedZoom = selectedId !== null; - const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; - - if (region && strength > 0 && !shouldShowUnzoomedView) { - const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - - // Cursor follow: use cursor-follow camera for non-manual zoom regions - let regionFocus = region.focus; - if ( - !zoomClassicModeRef.current && - region.mode !== "manual" && - cursorTelemetryRef.current.length > 0 - ) { - regionFocus = computeCursorFollowFocus( - cursorFollowCameraRef.current, - cursorTelemetryRef.current, - currentTimeRef.current, - zoomScale, - strength, - region.focus, - { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, - ); - } - - targetScaleFactor = zoomScale; - targetFocus = regionFocus; - targetProgress = strength; + // The export compositor advances exactly once for each output timestamp. + // Do the same here: repeated Pixi ticks at one media timestamp must not + // advance cursor springs or clear the blur calculated for that frame. + const contentTimeMs = currentTimeRef.current; + const previousContentTimeMs = lastRenderedContentTimeRef.current; + const deltaMs = + previousContentTimeMs !== null + ? contentTimeMs - previousContentTimeMs + : 1000 / 60; + const contentTimeChanged = + previousContentTimeMs === null || Math.abs(deltaMs) > 0.0001; + const motionMode = resolvePreviewMotionMode({ + isPlaying: isPlayingRef.current, + isSeeking: isSeekingRef.current, + shouldSnapPausedFrame: shouldSnapPausedFrameRef.current, + zoomClassicMode: zoomClassicModeRef.current, + }); + if ( + !shouldComposePreviewFrame({ + motionMode, + contentTimeChanged, + shouldSnapPausedFrame: shouldSnapPausedFrameRef.current, + }) + ) { + return; } + lastRenderedContentTimeRef.current = contentTimeMs; + + const target = resolveSceneZoomTarget({ + zoomRegions: zoomRegionsRef.current, + timeMs: currentTimeRef.current, + connectZooms: connectZoomsRef.current, + zoomInDurationMs: zoomInDurationMsRef.current, + zoomOutDurationMs: zoomOutDurationMsRef.current, + zoomClassicMode: zoomClassicModeRef.current, + cursorTelemetry: cursorTelemetryRef.current, + cursorFollowCamera: cursorFollowCameraRef.current, + }); const state = animationStateRef.current; - state.scale = targetScaleFactor; - state.focusX = targetFocus.cx; - state.focusY = targetFocus.cy; - state.progress = targetProgress; + state.scale = target.scale; + state.focusX = target.focus.cx; + state.focusY = target.focus.cy; + state.progress = target.progress; const projectedTransform = computeZoomTransform({ stageSize: stageSizeRef.current, @@ -2136,25 +2166,21 @@ const VideoPlayback = forwardRef( focusY: state.focusY, }); - // Spring-driven zoom animation - const now = performance.now(); - const deltaMs = - lastTickTimeRef.current !== null ? now - lastTickTimeRef.current : 1000 / 60; - lastTickTimeRef.current = now; + // Advance scene motion from the source frame's media timestamp, exactly as + // export does. Wall-clock ticker time makes speed regions and dropped UI + // frames produce a different camera path from the encoded output. + const contentAdvanced = previousContentTimeMs === null || deltaMs > 0; const zoomSpringConfig = getZoomSpringConfig(zoomSmoothnessRef.current, { stiffnessMultiplier: cameraSpringStiffnessMultiplierRef.current, dampingMultiplier: cameraSpringDampingMultiplierRef.current, massMultiplier: cameraSpringMassMultiplierRef.current, }); - const useSpring = - isPlayingRef.current && !isSeekingRef.current && !zoomClassicModeRef.current; - let appliedScale: number; let appliedX: number; let appliedY: number; - if (useSpring) { + if (motionMode === "spring" && contentAdvanced) { appliedScale = stepSpringValue( springScaleRef.current, projectedTransform.scale, @@ -2173,17 +2199,21 @@ const VideoPlayback = forwardRef( deltaMs, zoomSpringConfig, ); - } else { - // Snap instantly when paused, seeking, or in classic mode + } else if (motionMode === "snap") { + // Timeline seeks and classic mode intentionally evaluate the exact target. appliedScale = projectedTransform.scale; appliedX = projectedTransform.x; appliedY = projectedTransform.y; resetSpringState(springScaleRef.current, appliedScale); resetSpringState(springXRef.current, appliedX); resetSpringState(springYRef.current, appliedY); + } else { + appliedScale = state.appliedScale; + appliedX = state.x; + appliedY = state.y; } - applyTransform({ scale: appliedScale, x: appliedX, y: appliedY }, targetFocus); + applyTransform({ scale: appliedScale, x: appliedX, y: appliedY }, target.focus); applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); @@ -2195,9 +2225,15 @@ const VideoPlayback = forwardRef( timeMs, baseMaskRef.current, showCursorRef.current, - !isPlayingRef.current || isSeekingRef.current, + isSeekingRef.current || shouldSnapPausedFrameRef.current, ); } + + // Seeking events request one exact composition. Further Pixi ticks at the + // same media timestamp must hold it just like an exported frame. + if (shouldSnapPausedFrameRef.current) { + shouldSnapPausedFrameRef.current = false; + } }; app.ticker.add(ticker); @@ -2408,9 +2444,15 @@ const VideoPlayback = forwardRef( : resolvedWallpaperKind === "video" ? {} : { background: resolvedWallpaper || "" }; + const sceneEffects = getSceneEffectMetrics({ + viewportWidth: previewViewportWidth, + backgroundBlur, + shadowIntensity: showShadow ? shadowIntensity : 0, + }); + const captionFontFamily = autoCaptionSettings?.fontFamily || getDefaultCaptionFontFamily(); // Overscan blurred wallpaper layers so the browser never samples transparent // pixels beyond the preview bounds, which otherwise looks like a vignette. - const backgroundBlurOverscan = backgroundBlur > 0 ? Math.ceil(backgroundBlur * 2) : 0; + const backgroundBlurOverscan = sceneEffects.backgroundOverscanPx; const fallbackVideoClassName = pixiRendererError ? "absolute inset-0 h-full w-full object-cover" : "pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"; @@ -2455,7 +2497,10 @@ const VideoPlayback = forwardRef( loop playsInline style={{ - filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none", + filter: + sceneEffects.backgroundBlurPx > 0 + ? `blur(${sceneEffects.backgroundBlurPx}px)` + : "none", inset: -backgroundBlurOverscan, width: `calc(100% + ${backgroundBlurOverscan * 2}px)`, height: `calc(100% + ${backgroundBlurOverscan * 2}px)`, @@ -2466,7 +2511,10 @@ const VideoPlayback = forwardRef( className="absolute inset-0 bg-cover bg-center" style={{ ...backgroundStyle, - filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : "none", + filter: + sceneEffects.backgroundBlurPx > 0 + ? `blur(${sceneEffects.backgroundBlurPx}px)` + : "none", inset: -backgroundBlurOverscan, }} /> @@ -2475,10 +2523,7 @@ const VideoPlayback = forwardRef( ref={containerRef} className="absolute inset-0" style={{ - filter: - showShadow && shadowIntensity > 0 - ? `drop-shadow(0 ${shadowIntensity * 12}px ${shadowIntensity * 48}px rgba(0,0,0,${shadowIntensity * 0.7})) drop-shadow(0 ${shadowIntensity * 4}px ${shadowIntensity * 16}px rgba(0,0,0,${shadowIntensity * 0.5})) drop-shadow(0 ${shadowIntensity * 2}px ${shadowIntensity * 8}px rgba(0,0,0,${shadowIntensity * 0.3}))` - : "none", + filter: sceneEffects.shadowFilter, }} /> {hasRendererFallback && ( @@ -2565,8 +2610,7 @@ const VideoPlayback = forwardRef( maxWidth: `${autoCaptionSettings.maxWidth}%`, opacity: activeCaptionLayout.opacity, transform: `translateY(${activeCaptionLayout.translateY}px) scale(${activeCaptionLayout.scale})`, - transformOrigin: "center bottom", - filter: "drop-shadow(0 12px 30px rgba(0, 0, 0, 0.28))", + transformOrigin: "center center", }} >
( }} style={{ backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`, - fontFamily: getDefaultCaptionFontFamily(), + fontFamily: captionFontFamily, fontSize: `${getCaptionScaledFontSize( autoCaptionSettings.fontSize, overlayRef.current?.clientWidth || 960, @@ -2801,22 +2845,10 @@ const VideoPlayback = forwardRef( }} > {(() => { + const timeMs = Math.round(currentTime * 1000); const filtered = (annotationRegions || []).filter( - (annotation) => { - if ( - typeof annotation.startMs !== "number" || - typeof annotation.endMs !== "number" - ) - return false; - - if (annotation.id === selectedAnnotationId) return true; - - const timeMs = Math.round(currentTime * 1000); - return ( - timeMs >= annotation.startMs && - timeMs <= annotation.endMs - ); - }, + (annotation) => + isAnnotationActiveAtTime(annotation, timeMs), ); const sorted = [...filtered].sort( diff --git a/src/components/video-editor/videoPlayback/annotationVisibility.test.ts b/src/components/video-editor/videoPlayback/annotationVisibility.test.ts new file mode 100644 index 000000000..5913b402b --- /dev/null +++ b/src/components/video-editor/videoPlayback/annotationVisibility.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { isAnnotationActiveAtTime, shouldClearSelectedAnnotation } from "./annotationVisibility"; + +describe("isAnnotationActiveAtTime", () => { + it("includes both annotation range boundaries", () => { + const annotation = { startMs: 1_000, endMs: 2_000 }; + + expect(isAnnotationActiveAtTime(annotation, 1_000)).toBe(true); + expect(isAnnotationActiveAtTime(annotation, 2_000)).toBe(true); + }); + + it("excludes timestamps outside the annotation range", () => { + const annotation = { startMs: 1_000, endMs: 2_000 }; + + expect(isAnnotationActiveAtTime(annotation, 999)).toBe(false); + expect(isAnnotationActiveAtTime(annotation, 2_001)).toBe(false); + }); + + it("rejects invalid annotation timing", () => { + expect(isAnnotationActiveAtTime({ startMs: Number.NaN, endMs: 2_000 }, 1_500)).toBe(false); + }); +}); + +describe("shouldClearSelectedAnnotation", () => { + const annotation = { + id: "annotation-1", + startMs: 1_000, + endMs: 2_000, + } as never; + + it("clears selection after the playhead leaves its active range", () => { + expect(shouldClearSelectedAnnotation([annotation], annotation.id, 2_001)).toBe(true); + }); + + it("keeps selection while its annotation is active", () => { + expect(shouldClearSelectedAnnotation([annotation], annotation.id, 1_500)).toBe(false); + }); +}); diff --git a/src/components/video-editor/videoPlayback/annotationVisibility.ts b/src/components/video-editor/videoPlayback/annotationVisibility.ts new file mode 100644 index 000000000..5823c4c37 --- /dev/null +++ b/src/components/video-editor/videoPlayback/annotationVisibility.ts @@ -0,0 +1,30 @@ +import type { AnnotationRegion } from "../types"; + +/** Return whether an annotation is composited at the supplied media timestamp. */ +export function isAnnotationActiveAtTime( + annotation: Pick, + timeMs: number, +): boolean { + return ( + Number.isFinite(annotation.startMs) && + Number.isFinite(annotation.endMs) && + timeMs >= annotation.startMs && + timeMs <= annotation.endMs + ); +} + +/** Return whether the current playhead has left the selected annotation's range. */ +export function shouldClearSelectedAnnotation( + annotations: AnnotationRegion[], + selectedAnnotationId: string | null | undefined, + timeMs: number, +): boolean { + if (!selectedAnnotationId) { + return false; + } + + const selectedAnnotation = annotations.find( + (annotation) => annotation.id === selectedAnnotationId, + ); + return Boolean(selectedAnnotation && !isAnnotationActiveAtTime(selectedAnnotation, timeMs)); +} diff --git a/src/components/video-editor/videoPlayback/sceneEffects.test.ts b/src/components/video-editor/videoPlayback/sceneEffects.test.ts new file mode 100644 index 000000000..ff3f53b16 --- /dev/null +++ b/src/components/video-editor/videoPlayback/sceneEffects.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { getSceneEffectMetrics } from "./sceneEffects"; + +describe("getSceneEffectMetrics", () => { + it("keeps blur proportional between preview and export widths", () => { + const preview = getSceneEffectMetrics({ + viewportWidth: 640, + backgroundBlur: 8, + shadowIntensity: 0, + }); + const exportFrame = getSceneEffectMetrics({ + viewportWidth: 1920, + backgroundBlur: 8, + shadowIntensity: 0, + }); + + expect(preview.backgroundBlurPx).toBe(8); + expect(exportFrame.backgroundBlurPx).toBe(24); + expect(exportFrame.backgroundBlurPx / 1920).toBe(preview.backgroundBlurPx / 640); + }); + + it("uses the same proportional shadow recipe at every width", () => { + const preview = getSceneEffectMetrics({ + viewportWidth: 640, + backgroundBlur: 0, + shadowIntensity: 1, + }); + const exportFrame = getSceneEffectMetrics({ + viewportWidth: 1280, + backgroundBlur: 0, + shadowIntensity: 1, + }); + + expect(preview.shadowFilter).toContain("12px 48px"); + expect(exportFrame.shadowFilter).toContain("24px 96px"); + }); + + it("disables negative effect values", () => { + const metrics = getSceneEffectMetrics({ + viewportWidth: 640, + backgroundBlur: -4, + shadowIntensity: -1, + }); + + expect(metrics.backgroundBlurPx).toBe(0); + expect(metrics.backgroundOverscanPx).toBe(0); + expect(metrics.shadowFilter).toBe("none"); + }); +}); diff --git a/src/components/video-editor/videoPlayback/sceneEffects.ts b/src/components/video-editor/videoPlayback/sceneEffects.ts new file mode 100644 index 000000000..4cf543e23 --- /dev/null +++ b/src/components/video-editor/videoPlayback/sceneEffects.ts @@ -0,0 +1,42 @@ +const EFFECT_REFERENCE_WIDTH = 640; + +export type SceneEffectMetrics = { + viewportScale: number; + backgroundBlurPx: number; + backgroundOverscanPx: number; + shadowFilter: string; +}; + +/** + * Resolve CSS/canvas effect pixels from the rendered scene width. + * + * Preview and export render at very different pixel sizes. Treating effect + * settings as literal pixels makes the export visibly diverge from the editor. + * This reference width keeps the setting's appearance proportional at every + * resolution and gives both compositors one source of truth. + */ +export function getSceneEffectMetrics({ + viewportWidth, + backgroundBlur, + shadowIntensity, +}: { + viewportWidth: number; + backgroundBlur: number; + shadowIntensity: number; +}): SceneEffectMetrics { + const scale = Math.max(1, viewportWidth) / EFFECT_REFERENCE_WIDTH; + const blurPx = Math.max(0, backgroundBlur) * scale; + const intensity = Math.max(0, shadowIntensity); + const shadow = (offsetY: number, blur: number, alpha: number) => + `drop-shadow(0 ${offsetY * intensity * scale}px ${blur * intensity * scale}px rgba(0,0,0,${alpha * intensity}))`; + + return { + viewportScale: scale, + backgroundBlurPx: blurPx, + backgroundOverscanPx: Math.ceil(blurPx * 2), + shadowFilter: + intensity > 0 + ? [shadow(12, 48, 0.7), shadow(4, 16, 0.5), shadow(2, 8, 0.3)].join(" ") + : "none", + }; +} diff --git a/src/components/video-editor/videoPlayback/sceneMotion.test.ts b/src/components/video-editor/videoPlayback/sceneMotion.test.ts new file mode 100644 index 000000000..3c42588b3 --- /dev/null +++ b/src/components/video-editor/videoPlayback/sceneMotion.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { ZoomRegion } from "../types"; +import { createCursorFollowCameraState } from "./cursorFollowCamera"; +import { + resolvePreviewMotionMode, + resolveSceneZoomTarget, + shouldComposePreviewFrame, +} from "./sceneMotion"; + +const region: ZoomRegion = { + id: "zoom", + startMs: 0, + endMs: 4000, + depth: 2, + focus: { cx: 0.7, cy: 0.3 }, + mode: "manual", +}; + +describe("resolveSceneZoomTarget", () => { + it("returns the neutral camera when no zoom is active", () => { + expect( + resolveSceneZoomTarget({ + zoomRegions: [], + timeMs: 1000, + cursorFollowCamera: createCursorFollowCameraState(), + }), + ).toEqual({ scale: 1, focus: { cx: 0.5, cy: 0.5 }, progress: 0 }); + }); + + it("resolves the same manual target for every rendering backend", () => { + const target = resolveSceneZoomTarget({ + zoomRegions: [region], + timeMs: 2000, + cursorFollowCamera: createCursorFollowCameraState(), + }); + + expect(target.scale).toBeGreaterThan(1); + // The scene evaluator clamps focus so the zoom never exposes the stage edge. + expect(target.focus.cx).toBeCloseTo(2 / 3); + expect(target.focus.cy).toBeCloseTo(1 / 3); + expect(target.progress).toBe(1); + }); +}); + +describe("resolvePreviewMotionMode", () => { + it("preserves the composed frame on a plain pause", () => { + expect( + resolvePreviewMotionMode({ + isPlaying: false, + isSeeking: false, + shouldSnapPausedFrame: false, + zoomClassicMode: false, + }), + ).toBe("preserve"); + }); + + it("snaps paused frames only for an intentional timeline seek", () => { + expect( + resolvePreviewMotionMode({ + isPlaying: false, + isSeeking: false, + shouldSnapPausedFrame: true, + zoomClassicMode: false, + }), + ).toBe("snap"); + }); +}); + +describe("shouldComposePreviewFrame", () => { + it("holds every visual sample, including blur and cursor state, while paused", () => { + expect( + shouldComposePreviewFrame({ + motionMode: "preserve", + contentTimeChanged: true, + shouldSnapPausedFrame: false, + }), + ).toBe(false); + }); + + it("does not interpolate again at an unchanged playback timestamp", () => { + expect( + shouldComposePreviewFrame({ + motionMode: "spring", + contentTimeChanged: false, + shouldSnapPausedFrame: false, + }), + ).toBe(false); + }); + + it("composes one exact frame when a seek requests it", () => { + expect( + shouldComposePreviewFrame({ + motionMode: "snap", + contentTimeChanged: false, + shouldSnapPausedFrame: true, + }), + ).toBe(true); + }); +}); diff --git a/src/components/video-editor/videoPlayback/sceneMotion.ts b/src/components/video-editor/videoPlayback/sceneMotion.ts new file mode 100644 index 000000000..f749db304 --- /dev/null +++ b/src/components/video-editor/videoPlayback/sceneMotion.ts @@ -0,0 +1,109 @@ +import type { CursorTelemetryPoint, ZoomFocus, ZoomRegion } from "../types"; +import { ZOOM_DEPTH_SCALES } from "../types"; +import { DEFAULT_FOCUS } from "./constants"; +import { + type CursorFollowCameraState, + computeCursorFollowFocus, + SNAP_TO_EDGES_RATIO_AUTO, +} from "./cursorFollowCamera"; +import { findDominantRegion } from "./zoomRegionUtils"; + +export type SceneZoomTarget = { + scale: number; + focus: ZoomFocus; + progress: number; +}; + +export type PreviewMotionMode = "spring" | "snap" | "preserve"; + +/** + * Decide how the preview camera should react to the current transport state. + * A plain pause must preserve the last composed frame; recomputing the projected + * target there causes the image to jump as soon as the user presses Space. + */ +export function resolvePreviewMotionMode({ + isPlaying, + isSeeking, + shouldSnapPausedFrame, + zoomClassicMode, +}: { + isPlaying: boolean; + isSeeking: boolean; + shouldSnapPausedFrame: boolean; + zoomClassicMode: boolean; +}): PreviewMotionMode { + if (isSeeking || shouldSnapPausedFrame || zoomClassicMode) { + return "snap"; + } + + return isPlaying ? "spring" : "preserve"; +} + +/** Match export's one-composition-per-media-frame behavior. */ +export function shouldComposePreviewFrame({ + motionMode, + contentTimeChanged, + shouldSnapPausedFrame, +}: { + motionMode: PreviewMotionMode; + contentTimeChanged: boolean; + shouldSnapPausedFrame: boolean; +}): boolean { + if (motionMode === "preserve") { + return false; + } + + return contentTimeChanged || shouldSnapPausedFrame; +} + +/** Resolve the camera target for a media timestamp, independent of renderer. */ +export function resolveSceneZoomTarget({ + zoomRegions, + timeMs, + connectZooms, + zoomInDurationMs, + zoomOutDurationMs, + zoomClassicMode, + cursorTelemetry, + cursorFollowCamera, +}: { + zoomRegions: ZoomRegion[]; + timeMs: number; + connectZooms?: boolean; + zoomInDurationMs?: number; + zoomOutDurationMs?: number; + zoomClassicMode?: boolean; + cursorTelemetry?: CursorTelemetryPoint[]; + cursorFollowCamera: CursorFollowCameraState; +}): SceneZoomTarget { + const { region, strength, blendedScale } = findDominantRegion(zoomRegions, timeMs, { + connectZooms, + zoomInDurationMs, + zoomOutDurationMs, + }); + + if (!region || strength <= 0) { + return { scale: 1, focus: DEFAULT_FOCUS, progress: 0 }; + } + + const scale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + let focus = region.focus; + if ( + !zoomClassicMode && + region.mode !== "manual" && + cursorTelemetry && + cursorTelemetry.length > 0 + ) { + focus = computeCursorFollowFocus( + cursorFollowCamera, + cursorTelemetry, + timeMs, + scale, + strength, + region.focus, + { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, + ); + } + + return { scale, focus, progress: strength }; +} diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts index 52237aafa..bf3fd5413 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.test.ts @@ -174,9 +174,11 @@ describe("createVideoEventHandlers", () => { paused: true, }); const onTimeUpdate = vi.fn(); + const shouldSnapPausedFrameRef = createMutableRef(false); const handlers = createVideoEventHandlers({ video, isSeekingRef: createMutableRef(true), + shouldSnapPausedFrameRef, isPlayingRef: createMutableRef(false), allowPlaybackRef: createMutableRef(true), currentTimeRef: createMutableRef(0), @@ -187,9 +189,11 @@ describe("createVideoEventHandlers", () => { speedRegionsRef: createMutableRef([]), }); + handlers.handleSeeking(); handlers.handleSeeked(); expect(video.currentTime).toBe(2); expect(onTimeUpdate).toHaveBeenLastCalledWith(2); + expect(shouldSnapPausedFrameRef.current).toBe(true); }); }); diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts index d3ac2db59..4b6be5d2c 100644 --- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts +++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts @@ -16,6 +16,7 @@ type PresentedFrameVideoElement = HTMLVideoElement & { interface VideoEventHandlersParams { video: HTMLVideoElement; isSeekingRef: React.MutableRefObject; + shouldSnapPausedFrameRef?: React.MutableRefObject; isPlayingRef: React.MutableRefObject; allowPlaybackRef: React.MutableRefObject; currentTimeRef: React.MutableRefObject; @@ -26,10 +27,15 @@ interface VideoEventHandlersParams { speedRegionsRef: React.MutableRefObject; } +/** + * Bind media events to the preview's presented-frame clock while honoring trim + * and speed regions. + */ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const { video, isSeekingRef, + shouldSnapPausedFrameRef, isPlayingRef, allowPlaybackRef, currentTimeRef, @@ -178,6 +184,9 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) { const handleSeeking = () => { isSeekingRef.current = true; + if (shouldSnapPausedFrameRef) { + shouldSnapPausedFrameRef.current = true; + } emitTime(video.currentTime); }; diff --git a/src/lib/exporter/captionRenderer.ts b/src/lib/exporter/captionRenderer.ts index 1459eb7cb..03794acdc 100644 --- a/src/lib/exporter/captionRenderer.ts +++ b/src/lib/exporter/captionRenderer.ts @@ -15,6 +15,7 @@ import { } from "@/components/video-editor/types"; import { drawSquircleOnCanvas } from "@/lib/geometry/squircle"; +/** Draw the active caption using the same typography and timing as preview. */ export function renderCaptions( ctx: CanvasRenderingContext2D, cues: CaptionCue[], @@ -30,7 +31,7 @@ export function renderCaptions( ctx.save(); const fontSize = getCaptionScaledFontSize(settings.fontSize, width, settings.maxWidth); - ctx.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${getDefaultCaptionFontFamily()}`; + ctx.font = `${CAPTION_FONT_WEIGHT} ${fontSize}px ${settings.fontFamily || getDefaultCaptionFontFamily()}`; const padding = getCaptionPadding(fontSize); const activeCaptionLayout = buildActiveCaptionLayout({ diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 0bc5abef4..473071d01 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -20,14 +20,11 @@ import { BASE_PREVIEW_HEIGHT, BASE_PREVIEW_WIDTH, DEFAULT_WEBCAM_ROUNDNESS, - ZOOM_DEPTH_SCALES, } from "@/components/video-editor/types"; import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants"; import { type CursorFollowCameraState, - computeCursorFollowFocus, createCursorFollowCameraState, - SNAP_TO_EDGES_RATIO_AUTO, } from "@/components/video-editor/videoPlayback/cursorFollowCamera"; import { DEFAULT_CURSOR_CONFIG, @@ -45,8 +42,9 @@ import { type SpringState, 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 } from "@/components/video-editor/videoPlayback/webcamSync"; -import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; import { applyZoomTransform, computeZoomTransform, @@ -1663,50 +1661,20 @@ export class FrameRenderer { }; } + /** Advance the export camera from the shared scene target at this media time. */ private updateAnimationState(timeMs: number): number { if (!this.cameraContainer || !this.layoutCache) return 0; - const { region, strength, blendedScale } = findDominantRegion( - this.config.zoomRegions, + const target = resolveSceneZoomTarget({ + zoomRegions: this.config.zoomRegions, timeMs, - { - connectZooms: this.config.connectZooms, - zoomInDurationMs: this.config.zoomInDurationMs, - zoomOutDurationMs: this.config.zoomOutDurationMs, - }, - ); - - const defaultFocus = DEFAULT_FOCUS; - let targetScaleFactor = 1; - let targetFocus = { ...defaultFocus }; - let targetProgress = 0; - - if (region && strength > 0) { - const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - - // Cursor follow: use cursor-follow camera for non-manual zoom regions - let regionFocus = region.focus; - if ( - !this.config.zoomClassicMode && - region.mode !== "manual" && - this.config.cursorTelemetry && - this.config.cursorTelemetry.length > 0 - ) { - regionFocus = computeCursorFollowFocus( - this.cursorFollowCamera, - this.config.cursorTelemetry, - timeMs, - zoomScale, - strength, - region.focus, - { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, - ); - } - - targetScaleFactor = zoomScale; - targetFocus = regionFocus; - targetProgress = strength; - } + connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + zoomClassicMode: this.config.zoomClassicMode, + cursorTelemetry: this.config.cursorTelemetry, + cursorFollowCamera: this.cursorFollowCamera, + }); const state = this.animationState; @@ -1714,10 +1682,10 @@ export class FrameRenderer { const prevX = state.x; const prevY = state.y; - state.scale = targetScaleFactor; - state.focusX = targetFocus.cx; - state.focusY = targetFocus.cy; - state.progress = targetProgress; + state.scale = target.scale; + state.focusX = target.focus.cx; + state.focusY = target.focus.cy; + state.progress = target.progress; const projectedTransform = computeZoomTransform({ stageSize: this.layoutCache.stageSize, @@ -1910,6 +1878,11 @@ export class FrameRenderer { const ctx = this.compositeCtx; const w = this.compositeCanvas.width; const h = this.compositeCanvas.height; + const sceneEffects = getSceneEffectMetrics({ + viewportWidth: w, + backgroundBlur: this.config.backgroundBlur, + shadowIntensity: this.config.showShadow ? this.config.shadowIntensity : 0, + }); // Clear composite canvas ctx.clearRect(0, 0, w, h); @@ -1920,11 +1893,10 @@ export class FrameRenderer { if (this.backgroundSprite) { const bgCanvas = this.backgroundSprite; - if (this.config.backgroundBlur > 0) { + if (sceneEffects.backgroundBlurPx > 0) { ctx.save(); - const blurPx = this.config.backgroundBlur * 3; - const overscan = Math.ceil(blurPx * 2); - ctx.filter = `blur(${blurPx}px)`; + const overscan = sceneEffects.backgroundOverscanPx; + ctx.filter = `blur(${sceneEffects.backgroundBlurPx}px)`; ctx.drawImage(bgCanvas, -overscan, -overscan, w + overscan * 2, h + overscan * 2); ctx.restore(); } else { @@ -1947,17 +1919,7 @@ export class FrameRenderer { shadowCtx.imageSmoothingQuality = "high"; shadowCtx.save(); - // Calculate shadow parameters based on intensity (0-1) - const intensity = this.config.shadowIntensity; - const baseBlur1 = 48 * intensity; - const baseBlur2 = 16 * intensity; - const baseBlur3 = 8 * intensity; - const baseAlpha1 = 0.7 * intensity; - const baseAlpha2 = 0.5 * intensity; - const baseAlpha3 = 0.3 * intensity; - const baseOffset = 12 * intensity; - - shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`; + shadowCtx.filter = sceneEffects.shadowFilter; shadowCtx.drawImage(videoCanvas, 0, 0, w, h); shadowCtx.restore(); ctx.drawImage(this.shadowCanvas, 0, 0, w, h); diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index d5fb8f6d5..25dc33c39 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -29,14 +29,11 @@ import type { import { DEFAULT_WEBCAM_ROUNDNESS, getDefaultCaptionFontFamily, - ZOOM_DEPTH_SCALES, } from "@/components/video-editor/types"; import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants"; import { type CursorFollowCameraState, - computeCursorFollowFocus, createCursorFollowCameraState, - SNAP_TO_EDGES_RATIO_AUTO, } from "@/components/video-editor/videoPlayback/cursorFollowCamera"; import { DEFAULT_CURSOR_CONFIG, @@ -54,8 +51,9 @@ import { type SpringState, 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 } from "@/components/video-editor/videoPlayback/webcamSync"; -import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; import { applyZoomTransform, computeZoomTransform, @@ -1338,8 +1336,13 @@ export class FrameRenderer { this.backgroundContainer.addChild(this.backgroundSprite); if (this.config.backgroundBlur > 0) { + const sceneEffects = getSceneEffectMetrics({ + viewportWidth: this.config.width, + backgroundBlur: this.config.backgroundBlur, + shadowIntensity: 0, + }); this.backgroundBlurFilter = new BlurFilter(); - this.backgroundBlurFilter.blur = this.config.backgroundBlur * 3; + this.backgroundBlurFilter.blur = sceneEffects.backgroundBlurPx; this.backgroundBlurFilter.quality = 4; this.backgroundBlurFilter.resolution = this.app?.renderer.resolution ?? 1; this.backgroundBlurFilter.repeatEdgePixels = true; @@ -1373,8 +1376,13 @@ export class FrameRenderer { } blurredCtx.save(); - const blurPx = this.config.backgroundBlur * 3; - const overscan = Math.ceil(blurPx * 2); + const sceneEffects = getSceneEffectMetrics({ + viewportWidth: this.config.width, + backgroundBlur: this.config.backgroundBlur, + shadowIntensity: 0, + }); + const blurPx = sceneEffects.backgroundBlurPx; + const overscan = sceneEffects.backgroundOverscanPx; blurredCtx.filter = `blur(${blurPx}px)`; blurredCtx.drawImage( sourceCanvas, @@ -3310,13 +3318,18 @@ export class FrameRenderer { maskRadius: number; }): void { const shadowStrength = clampUnitInterval(this.config.shadowIntensity); + const effectScale = getSceneEffectMetrics({ + viewportWidth: this.config.width, + backgroundBlur: 0, + shadowIntensity: shadowStrength, + }).viewportScale; for (const layer of this.videoShadowLayers) { if (!this.config.showShadow || shadowStrength <= 0) { layer.container.visible = false; continue; } - const offsetY = layer.offsetScale * shadowStrength; + const offsetY = layer.offsetScale * effectScale * shadowStrength; this.rasterizeShadowLayer(layer, { x: layout.maskX, y: layout.maskY, @@ -3325,66 +3338,37 @@ export class FrameRenderer { radius: layout.maskRadius, offsetY, alpha: layer.alphaScale * shadowStrength, - blur: Math.max(0, layer.blurScale * shadowStrength), + blur: Math.max(0, layer.blurScale * effectScale * shadowStrength), }); } } + /** Advance the export camera from the shared scene target at this media time. */ private updateAnimationState(timeMs: number): number { if (!this.cameraContainer || !this.layoutCache) { return 0; } - const { region, strength, blendedScale } = findDominantRegion( - this.config.zoomRegions, + const target = resolveSceneZoomTarget({ + zoomRegions: this.config.zoomRegions, timeMs, - { - connectZooms: this.config.connectZooms, - zoomInDurationMs: this.config.zoomInDurationMs, - zoomOutDurationMs: this.config.zoomOutDurationMs, - }, - ); - - let targetScaleFactor = 1; - let targetFocus = { ...DEFAULT_FOCUS }; - let targetProgress = 0; - - if (region && strength > 0) { - const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - - // Cursor follow: use cursor-follow camera for non-manual zoom regions - let regionFocus = region.focus; - if ( - !this.config.zoomClassicMode && - region.mode !== "manual" && - this.config.cursorTelemetry && - this.config.cursorTelemetry.length > 0 - ) { - regionFocus = computeCursorFollowFocus( - this.cursorFollowCamera, - this.config.cursorTelemetry, - timeMs, - zoomScale, - strength, - region.focus, - { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, - ); - } - - targetScaleFactor = zoomScale; - targetFocus = regionFocus; - targetProgress = strength; - } + connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + zoomClassicMode: this.config.zoomClassicMode, + cursorTelemetry: this.config.cursorTelemetry, + cursorFollowCamera: this.cursorFollowCamera, + }); const state = this.animationState; const previousScale = state.appliedScale; const previousX = state.x; const previousY = state.y; - state.scale = targetScaleFactor; - state.focusX = targetFocus.cx; - state.focusY = targetFocus.cy; - state.progress = targetProgress; + state.scale = target.scale; + state.focusX = target.focus.cx; + state.focusY = target.focus.cy; + state.progress = target.progress; const projectedTransform = computeZoomTransform({ stageSize: this.layoutCache.stageSize, diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 50f0e3bab..b06d0f80a 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { AudioRegion, SpeedRegion } from "@/components/video-editor/types"; +import type { AudioRegion, SpeedRegion, ZoomRegion } from "@/components/video-editor/types"; import { ModernVideoExporter } from "./modernVideoExporter"; import type { DecodedVideoInfo } from "./streamingDecoder"; @@ -70,6 +70,16 @@ function createExporter(overrides: Record = {}) { wallpaper: string, ) => CanvasGradient | null; getNativeStaticLayoutCursorSize: (contentWidth: number) => number; + getNativeStaticLayoutZoomTelemetry: ( + layout: { + centerOffsetX: number; + centerOffsetY: number; + croppedDisplayWidth: number; + croppedDisplayHeight: number; + }, + totalFrames: number, + cursorTelemetry: undefined, + ) => Array<{ timeMs: number; scale: number; x: number; y: number }> | undefined; }; } @@ -78,6 +88,32 @@ afterEach(() => { }); describe("ModernVideoExporter native static-layout eligibility", () => { + it("uses configured zoom transition durations for native telemetry", () => { + const zoomRegions: ZoomRegion[] = [ + { + id: "zoom-1", + startMs: 0, + endMs: 4_000, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + mode: "manual", + }, + ]; + const layout = { + centerOffsetX: 0, + centerOffsetY: 0, + croppedDisplayWidth: 1920, + croppedDisplayHeight: 1080, + }; + const fastExporter = createExporter({ zoomRegions, zoomInDurationMs: 100 }); + const slowExporter = createExporter({ zoomRegions, zoomInDurationMs: 2_000 }); + + const fastSamples = fastExporter.getNativeStaticLayoutZoomTelemetry(layout, 60, undefined); + const slowSamples = slowExporter.getNativeStaticLayoutZoomTelemetry(layout, 60, undefined); + + expect(fastSamples?.[30].scale).toBeGreaterThan(slowSamples?.[30].scale ?? 0); + }); + it("allows native static-layout eligibility for VP9/WebM sources so main can proxy them", () => { const exporter = createExporter(); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 3bb58eb8f..b14c11a55 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -17,13 +17,8 @@ import type { ZoomRegion, ZoomTransitionEasing, } from "@/components/video-editor/types"; -import { DEFAULT_WEBCAM_ROUNDNESS, ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; -import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants"; -import { - computeCursorFollowFocus, - createCursorFollowCameraState, - SNAP_TO_EDGES_RATIO_AUTO, -} from "@/components/video-editor/videoPlayback/cursorFollowCamera"; +import { DEFAULT_WEBCAM_ROUNDNESS } from "@/components/video-editor/types"; +import { createCursorFollowCameraState } from "@/components/video-editor/videoPlayback/cursorFollowCamera"; import { buildNativeCursorAtlas } from "@/components/video-editor/videoPlayback/cursorRenderer"; import { getCursorViewportScale } from "@/components/video-editor/videoPlayback/cursorScale"; import { @@ -36,8 +31,9 @@ import { resetSpringState, stepSpringValue, } from "@/components/video-editor/videoPlayback/motionSmoothing"; +import { getSceneEffectMetrics } from "@/components/video-editor/videoPlayback/sceneEffects"; +import { resolveSceneZoomTarget } from "@/components/video-editor/videoPlayback/sceneMotion"; import { getCursorStyleSizeMultiplier } from "@/components/video-editor/videoPlayback/uploadedCursorAssets"; -import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils"; import { computeZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform"; import { getWebcamCornerRadiusPx, @@ -2100,6 +2096,7 @@ export class ModernVideoExporter { ); } + /** Build deterministic per-frame camera transforms for native compositors. */ private getNativeStaticLayoutZoomTelemetry( layout: ReturnType, totalFrames: number, @@ -2141,45 +2138,24 @@ export class ModernVideoExporter { for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) { const timeMs = frameIndex * frameDurationMs; - const { region, strength, blendedScale } = findDominantRegion(zoomRegions, timeMs, { + const target = resolveSceneZoomTarget({ + zoomRegions, + timeMs, connectZooms: this.config.connectZooms, + zoomInDurationMs: this.config.zoomInDurationMs, + zoomOutDurationMs: this.config.zoomOutDurationMs, + zoomClassicMode: this.config.zoomClassicMode, + cursorTelemetry: cursorTelemetry ?? [], + cursorFollowCamera, }); - let targetScale = 1; - let targetFocus = DEFAULT_FOCUS; - let targetProgress = 0; - - if (region && strength > 0) { - const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; - let regionFocus = region.focus; - if ( - !this.config.zoomClassicMode && - region.mode !== "manual" && - (cursorTelemetry?.length ?? 0) > 0 - ) { - regionFocus = computeCursorFollowFocus( - cursorFollowCamera, - cursorTelemetry ?? [], - timeMs, - zoomScale, - strength, - region.focus, - { snapToEdgesRatio: SNAP_TO_EDGES_RATIO_AUTO }, - ); - } - - targetScale = zoomScale; - targetFocus = regionFocus; - targetProgress = strength; - } - const projectedTransform = computeZoomTransform({ stageSize, baseMask, - zoomScale: targetScale, - zoomProgress: targetProgress, - focusX: targetFocus.cx, - focusY: targetFocus.cy, + zoomScale: target.scale, + zoomProgress: target.progress, + focusX: target.focus.cx, + focusY: target.focus.cy, }); const deltaMs = lastContentTimeMs !== null ? timeMs - lastContentTimeMs : frameDurationMs; @@ -2482,7 +2458,11 @@ export class ModernVideoExporter { sourceCropHeight: sourceCrop?.height, backgroundColor: background.backgroundColor, backgroundImagePath: background.backgroundImagePath ?? null, - backgroundBlurPx: Math.max(0, (this.config.backgroundBlur ?? 0) * 3), + backgroundBlurPx: getSceneEffectMetrics({ + viewportWidth: this.config.width, + backgroundBlur: this.config.backgroundBlur ?? 0, + shadowIntensity: 0, + }).backgroundBlurPx, borderRadius, shadowIntensity, webcamInputPath: webcamOverlay?.inputPath ?? null,