diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 8b289e3f5..b1636ba05 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -626,6 +626,10 @@ interface SettingsPanelProps {
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationBlurIntensityChange?: (id: string, intensity: number) => void;
onAnnotationBlurColorChange?: (id: string, color: string) => void;
+ onAnnotationSpotlightOpacityChange?: (id: string, opacity: number) => void;
+ onApplySpotlightOpacityToAll?: (opacity: number) => void;
+ onAnnotationDisabledChange?: (id: string, disabled: boolean) => void;
+ onAnnotationFocusStart?: (startMs: number) => void;
onAnnotationDelete?: (id: string) => void;
autoCaptions?: CaptionCue[];
autoCaptionSettings?: AutoCaptionSettings;
@@ -1074,6 +1078,10 @@ export function SettingsPanel({
onAnnotationFigureDataChange,
onAnnotationBlurIntensityChange,
onAnnotationBlurColorChange,
+ onAnnotationSpotlightOpacityChange,
+ onApplySpotlightOpacityToAll,
+ onAnnotationDisabledChange,
+ onAnnotationFocusStart,
onAnnotationDelete,
autoCaptions = [],
autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS,
@@ -2058,6 +2066,23 @@ export function SettingsPanel({
? (color) => onAnnotationBlurColorChange(selectedAnnotation.id, color)
: undefined
}
+ onSpotlightOpacityChange={
+ onAnnotationSpotlightOpacityChange
+ ? (opacity) =>
+ onAnnotationSpotlightOpacityChange(selectedAnnotation.id, opacity)
+ : undefined
+ }
+ onApplySpotlightOpacityToAll={onApplySpotlightOpacityToAll}
+ onDisabledChange={
+ onAnnotationDisabledChange
+ ? (disabled) => onAnnotationDisabledChange(selectedAnnotation.id, disabled)
+ : undefined
+ }
+ onFocusStart={
+ onAnnotationFocusStart
+ ? () => onAnnotationFocusStart(selectedAnnotation.startMs)
+ : undefined
+ }
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
/>
);
diff --git a/src/components/video-editor/SpotlightMaskOverlay.tsx b/src/components/video-editor/SpotlightMaskOverlay.tsx
new file mode 100644
index 000000000..8d70a2bf4
--- /dev/null
+++ b/src/components/video-editor/SpotlightMaskOverlay.tsx
@@ -0,0 +1,84 @@
+import { useEffect, useRef } from "react";
+import {
+ getActiveSpotlights,
+ getSpotlightDimAlpha,
+ getSpotlightHoleStrengths,
+ paintSpotlightMask,
+ SPOTLIGHT_CORNER_RADIUS,
+} from "@/lib/spotlight/spotlightMask";
+import { type AnnotationRegion, BASE_PREVIEW_WIDTH } from "./types";
+
+const MAX_CANVAS_EDGE = 4096;
+
+interface SpotlightMaskOverlayProps {
+ annotations: AnnotationRegion[];
+ timeMs: number;
+ /** Size of the video (recording) rect in unscaled preview pixels. */
+ width: number;
+ height: number;
+ /** Rounded corner radius of the video in unscaled preview pixels. */
+ videoCornerRadius: number;
+ /** Current scene zoom, used to keep the mask edges sharp while zoomed in. */
+ sceneScale: number;
+}
+
+/** Dims the preview video outside every active spotlight annotation. */
+export function SpotlightMaskOverlay({
+ annotations,
+ timeMs,
+ width,
+ height,
+ videoCornerRadius,
+ sceneScale,
+}: SpotlightMaskOverlayProps) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const ctx = canvas?.getContext("2d");
+ if (!canvas || !ctx || width <= 0 || height <= 0) return;
+
+ const pixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
+ const resolution = Math.min(
+ pixelRatio * Math.max(1, sceneScale),
+ MAX_CANVAS_EDGE / Math.max(width, height),
+ );
+ const pixelWidth = Math.max(1, Math.round(width * resolution));
+ const pixelHeight = Math.max(1, Math.round(height * resolution));
+ if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
+ canvas.width = pixelWidth;
+ canvas.height = pixelHeight;
+ }
+
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ const spotlights = getActiveSpotlights(annotations, timeMs);
+ if (spotlights.length === 0) return;
+
+ const strengths = getSpotlightHoleStrengths(spotlights, timeMs);
+ ctx.setTransform(resolution, 0, 0, resolution, 0, 0);
+ paintSpotlightMask(ctx, {
+ area: { x: 0, y: 0, width, height },
+ areaRadius: videoCornerRadius,
+ holes: spotlights.map((spotlight, index) => ({
+ x: (spotlight.position.x / 100) * width,
+ y: (spotlight.position.y / 100) * height,
+ width: (spotlight.size.width / 100) * width,
+ height: (spotlight.size.height / 100) * height,
+ strength: strengths[index],
+ })),
+ holeRadius: SPOTLIGHT_CORNER_RADIUS * (width / BASE_PREVIEW_WIDTH),
+ alpha: getSpotlightDimAlpha(spotlights, timeMs),
+ });
+ }, [annotations, timeMs, width, height, videoCornerRadius, sceneScale]);
+
+ return (
+
+ );
+}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 842343dc3..f65f55b8b 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -42,6 +42,7 @@ import {
getCaptionTextMaxWidth,
getCaptionWordVisualState,
} from "./captionStyle";
+import { SpotlightMaskOverlay } from "./SpotlightMaskOverlay";
import {
type AnnotationRegion,
type AutoCaptionSettings,
@@ -103,7 +104,10 @@ import {
preloadCursorAssets,
} from "./videoPlayback/cursorRenderer";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
-import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
+import {
+ layoutVideoContent as layoutVideoContentUtil,
+ scalePreviewBorderRadius,
+} from "./videoPlayback/layoutUtils";
import { clamp01 } from "./videoPlayback/mathUtils";
import {
createSpringState,
@@ -1047,6 +1051,11 @@ const VideoPlayback = forwardRef(
// Reset camera container to identity
cameraContainer.scale.set(1);
cameraContainer.position.set(0, 0);
+ // The reset drops the active zoom from the Pixi scene while the annotation
+ // overlay keeps its last scene transform. While paused, the ticker only
+ // recomposes on request, so ask for one to re-apply the zoom and keep
+ // annotation editing aligned with the visible frame.
+ requestPausedFrameRefresh();
const selectedId = selectedZoomIdRef.current;
const activeRegion = selectedId
@@ -1058,6 +1067,7 @@ const VideoPlayback = forwardRef(
}
}, [
updateOverlayForRegion,
+ requestPausedFrameRefresh,
cropRegion,
borderRadius,
padding,
@@ -2445,6 +2455,54 @@ const VideoPlayback = forwardRef(
) : null}
+ {(() => {
+ // Spotlight dimming sits below captions so, as in export, captions are
+ // never dimmed. Drag handles stay in the annotation layer above.
+ const spotlightAreaWidth =
+ annotationRecordingRect.width ||
+ overlayRef.current?.clientWidth ||
+ 800;
+ const spotlightAreaHeight =
+ annotationRecordingRect.height ||
+ overlayRef.current?.clientHeight ||
+ 600;
+ const spotlightTimeMs = Math.round(timelineTime * 1000);
+
+ return (
+