mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Make preview frames match exported video more closely
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user