Make preview frames match exported video more closely

This commit is contained in:
webadderall
2026-09-11 17:29:58 +10:00
parent a1fbfe7a6a
commit 6f4c2cee53
12 changed files with 490 additions and 253 deletions
@@ -120,14 +120,14 @@ export function AnnotationOverlay({
? "flex-end"
: "center",
alignItems: "center",
padding: `${8 * sceneTransform.scale}px`,
padding: `${8 * sizeScale}px`,
}}
>
<span
style={{
color: annotation.style.color,
backgroundColor: annotation.style.backgroundColor,
fontSize: `${annotation.style.fontSize * sceneTransform.scale}px`,
fontSize: `${annotation.style.fontSize * sizeScale}px`,
fontFamily: annotation.style.fontFamily,
fontWeight: annotation.style.fontWeight,
fontStyle: annotation.style.fontStyle,
@@ -138,7 +138,7 @@ export function AnnotationOverlay({
boxDecorationBreak: "clone",
WebkitBoxDecorationBreak: "clone",
padding: "0.1em 0.2em",
borderRadius: `${4 * sceneTransform.scale}px`,
borderRadius: `${4 * sizeScale}px`,
lineHeight: "1.4",
}}
>
@@ -174,7 +174,10 @@ export function AnnotationOverlay({
}
return (
<div className="w-full h-full flex items-center justify-center p-2">
<div
className="w-full h-full flex items-center justify-center"
style={{ padding: `${8 * sizeScale}px` }}
>
{renderArrow()}
</div>
);
+96 -91
View File
@@ -80,7 +80,6 @@ import {
type SpeedRegion,
type TrimRegion,
type WebcamOverlaySettings,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
type ZoomMotionBlurTuning,
@@ -90,10 +89,7 @@ import {
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 +108,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 +407,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [pixiRendererBackend, setPixiRendererBackend] = useState<PixiPreviewBackend | null>(
null,
);
const [previewViewportWidth, setPreviewViewportWidth] = useState(640);
const [annotationSceneTransform, setAnnotationSceneTransform] =
useState<SceneTransformState>({
scale: 1,
@@ -467,6 +469,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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,7 +519,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const springScaleRef = useRef<SpringState>(createSpringState(1));
const springXRef = useRef<SpringState>(createSpringState(0));
const springYRef = useRef<SpringState>(createSpringState(0));
const lastTickTimeRef = useRef<number | null>(null);
const lastRenderedContentTimeRef = useRef<number | null>(null);
const zoomSmoothnessRef = useRef(zoomSmoothness);
const zoomClassicModeRef = useRef(zoomClassicMode);
const cursorFollowCameraRef = useRef<CursorFollowCameraState>(
@@ -630,7 +633,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +668,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +1030,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
@@ -1248,14 +1256,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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;
}
const bgVideo = bgVideoRef.current;
if (bgVideo) {
if (isPlaying) {
@@ -1976,6 +1976,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
createVideoEventHandlers({
video,
isSeekingRef,
shouldSnapPausedFrameRef,
isPlayingRef,
allowPlaybackRef,
currentTimeRef,
@@ -2045,7 +2046,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
motionBlurTuning: zoomMotionBlurTuningRef.current,
transformOverride: transform,
motionBlurState: motionBlurStateRef.current,
frameTimeMs: performance.now(),
frameTimeMs: currentTimeRef.current,
});
state.x = appliedTransform.x;
@@ -2073,59 +2074,51 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +2129,21 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +2162,21 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +2188,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +2407,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
: 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 +2460,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +2474,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +2486,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +2573,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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",
}}
>
<div
@@ -2605,7 +2612,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}}
style={{
backgroundColor: `rgba(0, 0, 0, ${autoCaptionSettings.backgroundOpacity})`,
fontFamily: getDefaultCaptionFontFamily(),
fontFamily: captionFontFamily,
fontSize: `${getCaptionScaledFontSize(
autoCaptionSettings.fontSize,
overlayRef.current?.clientWidth || 960,
@@ -2809,8 +2816,6 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
)
return false;
if (annotation.id === selectedAnnotationId) return true;
const timeMs = Math.round(currentTime * 1000);
return (
timeMs >= annotation.startMs &&
@@ -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");
});
});
@@ -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",
};
}
@@ -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);
});
});
@@ -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 };
}
@@ -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);
});
});
@@ -16,6 +16,7 @@ type PresentedFrameVideoElement = HTMLVideoElement & {
interface VideoEventHandlersParams {
video: HTMLVideoElement;
isSeekingRef: React.MutableRefObject<boolean>;
shouldSnapPausedFrameRef?: React.MutableRefObject<boolean>;
isPlayingRef: React.MutableRefObject<boolean>;
allowPlaybackRef: React.MutableRefObject<boolean>;
currentTimeRef: React.MutableRefObject<number>;
@@ -30,6 +31,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const {
video,
isSeekingRef,
shouldSnapPausedFrameRef,
isPlayingRef,
allowPlaybackRef,
currentTimeRef,
@@ -178,6 +180,9 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
const handleSeeking = () => {
isSeekingRef.current = true;
if (shouldSnapPausedFrameRef) {
shouldSnapPausedFrameRef.current = true;
}
emitTime(video.currentTime);
};
+1 -1
View File
@@ -30,7 +30,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({
+24 -63
View File
@@ -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,
@@ -1666,47 +1664,16 @@ export class FrameRenderer {
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 +1681,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 +1877,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 +1892,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 +1918,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);
+35 -52
View File
@@ -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,7 +3338,7 @@ 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),
});
}
}
@@ -3335,56 +3348,26 @@ export class FrameRenderer {
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,
+19 -42
View File
@@ -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,
@@ -2141,45 +2137,22 @@ 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,
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 +2455,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,