mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-26 07:45:34 +00:00
Merge pull request #926 from webadderallorg/codex/increase-preview-accuracy
Increase preview accuracy
This commit is contained in:
@@ -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`,
|
||||
}}
|
||||
>
|
||||
<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 +139,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 +175,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,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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const [pixiRendererBackend, setPixiRendererBackend] = useState<PixiPreviewBackend | null>(
|
||||
null,
|
||||
);
|
||||
const [previewViewportWidth, setPreviewViewportWidth] = useState(640);
|
||||
const [annotationSceneTransform, setAnnotationSceneTransform] =
|
||||
useState<SceneTransformState>({
|
||||
scale: 1,
|
||||
@@ -467,6 +473,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,12 +523,18 @@ 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>(
|
||||
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<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 +678,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 +1040,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;
|
||||
@@ -1240,22 +1258,31 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
zoomOutDurationMsRef.current = zoomOutDurationMs;
|
||||
}, [zoomOutDurationMs]);
|
||||
requestPausedFrameRefresh();
|
||||
}, [zoomOutDurationMs, requestPausedFrameRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
connectedZoomGapMsRef.current = connectedZoomGapMs;
|
||||
@@ -1438,11 +1468,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
|
||||
useEffect(() => {
|
||||
zoomMotionBlurRef.current = zoomMotionBlur;
|
||||
requestPausedFrameRefresh();
|
||||
|
||||
const videoEffectsContainer = videoEffectsContainerRef.current;
|
||||
const zoomBlurFilter = zoomBlurFilterRef.current;
|
||||
@@ -1498,15 +1531,17 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
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<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
createVideoEventHandlers({
|
||||
video,
|
||||
isSeekingRef,
|
||||
shouldSnapPausedFrameRef,
|
||||
isPlayingRef,
|
||||
allowPlaybackRef,
|
||||
currentTimeRef,
|
||||
@@ -2045,7 +2083,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 +2111,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 +2166,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 +2199,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 +2225,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 +2444,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 +2497,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 +2511,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 +2523,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 +2610,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 +2649,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,
|
||||
@@ -2801,22 +2845,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AnnotationRegion, "startMs" | "endMs">,
|
||||
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));
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -26,10 +27,15 @@ interface VideoEventHandlersParams {
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -2266,6 +2262,7 @@ export class ModernVideoExporter {
|
||||
);
|
||||
}
|
||||
|
||||
/** Build deterministic per-frame camera transforms for native compositors. */
|
||||
private getNativeStaticLayoutZoomTelemetry(
|
||||
layout: ReturnType<typeof computePaddedLayout>,
|
||||
totalFrames: number,
|
||||
@@ -2307,45 +2304,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;
|
||||
@@ -2648,7 +2624,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