diff --git a/src/components/video-editor/AnnotationOverlay.tsx b/src/components/video-editor/AnnotationOverlay.tsx index 4bdd44eca..353dd2ae3 100644 --- a/src/components/video-editor/AnnotationOverlay.tsx +++ b/src/components/video-editor/AnnotationOverlay.tsx @@ -1,5 +1,6 @@ import { useRef } from "react"; import { Rnd } from "react-rnd"; +import { SPOTLIGHT_CORNER_RADIUS } from "@/lib/spotlight/spotlightMask"; import { cn } from "@/lib/utils"; import { getArrowComponent } from "./ArrowSvgs"; import { type AnnotationRegion, BASE_PREVIEW_WIDTH, BLUR_ANNOTATION_STRENGTH } from "./types"; @@ -201,6 +202,21 @@ export function AnnotationOverlay({ ); } + case "spotlight": + // The dimming itself is drawn by SpotlightMaskOverlay; this box only + // provides the drag and resize handles. + return ( +
+ ); + default: return null; } @@ -300,6 +316,7 @@ export function AnnotationOverlay({ annotation.type === "text" && "bg-transparent", annotation.type === "image" && "bg-transparent", annotation.type === "figure" && "bg-transparent", + annotation.type === "spotlight" && "bg-transparent", isSelected && "shadow-lg", )} > diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index af5326320..d582d9445 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -4,6 +4,7 @@ import { AlignRight, TextB as Bold, CaretDown as ChevronDown, + Flashlight, ImageSquare as ImageIcon, Info, TextItalic as Italic, @@ -26,6 +27,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; +import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; @@ -33,7 +35,13 @@ import { cn } from "@/lib/utils"; import { useScopedT } from "../../contexts/I18nContext"; import { AddCustomFontDialog } from "./AddCustomFontDialog"; import { getArrowComponent } from "./ArrowSvgs"; -import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types"; +import { + type AnnotationRegion, + type AnnotationType, + type ArrowDirection, + DEFAULT_SPOTLIGHT_OPACITY, + type FigureData, +} from "./types"; interface AnnotationSettingsPanelProps { annotation: AnnotationRegion; @@ -43,6 +51,10 @@ interface AnnotationSettingsPanelProps { onFigureDataChange?: (figureData: FigureData) => void; onBlurIntensityChange?: (intensity: number) => void; onBlurColorChange?: (color: string) => void; + onSpotlightOpacityChange?: (opacity: number) => void; + onApplySpotlightOpacityToAll?: (opacity: number) => void; + onDisabledChange?: (disabled: boolean) => void; + onFocusStart?: () => void; onDelete: () => void; } @@ -67,6 +79,10 @@ export function AnnotationSettingsPanel({ onFigureDataChange, onBlurIntensityChange, onBlurColorChange, + onSpotlightOpacityChange, + onApplySpotlightOpacityToAll, + onDisabledChange, + onFocusStart, onDelete, }: AnnotationSettingsPanelProps) { const t = useScopedT("editor"); @@ -157,7 +173,7 @@ export function AnnotationSettingsPanel({ onValueChange={(value) => onTypeChange(value as AnnotationType)} className="mb-6" > - + {t("annotations.blur")} + + + {t("annotations.spotlight")} + {/* Text Content */} @@ -660,6 +683,90 @@ export function AnnotationSettingsPanel({
+ + {onFocusStart && ( + + )} + +
+
+ + {t("annotations.spotlightOpacity", undefined, { + opacity: Math.round( + annotation.spotlightOpacity ?? + DEFAULT_SPOTLIGHT_OPACITY, + ), + })} + + +
+ onSpotlightOpacityChange?.(value)} + min={0} + max={100} + step={1} + className="w-full" + /> + {onApplySpotlightOpacityToAll && ( + + )} +
+ + {onDisabledChange && ( +
+
+ + {t("annotations.disableSpotlight")} + + + {t("annotations.disableSpotlightDescription")} + +
+ +
+ )} +
+
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 ( +
) : 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 ( +
+
+ +
+
+ ); + })()} {!isGap && activeCaptionLayout && autoCaptionSettings ? (
{ + it("keeps values within 0-100", () => { + expect(clampSpotlightOpacity(-5)).toBe(0); + expect(clampSpotlightOpacity(42)).toBe(42); + expect(clampSpotlightOpacity(250)).toBe(100); + }); + + it("falls back to the default for non-finite input", () => { + expect(clampSpotlightOpacity(Number.NaN)).toBe(50); + }); +}); diff --git a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts index e6cd9942c..f084271d7 100644 --- a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts +++ b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts @@ -6,7 +6,10 @@ import { DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_FIGURE_DATA, + DEFAULT_SPOTLIGHT_OPACITY, type FigureData, + MAX_SPOTLIGHT_OPACITY, + MIN_SPOTLIGHT_OPACITY, } from "../types"; interface UseAnnotationRegionCommandsParams { @@ -16,6 +19,12 @@ interface UseAnnotationRegionCommandsParams { setSelectedZoomId: Dispatch>; nextAnnotationIdRef: MutableRefObject; nextAnnotationZIndexRef: MutableRefObject; + handleSeek?: (time: number, options?: { pause?: boolean }) => void; +} + +export function clampSpotlightOpacity(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_SPOTLIGHT_OPACITY; + return Math.min(MAX_SPOTLIGHT_OPACITY, Math.max(MIN_SPOTLIGHT_OPACITY, value)); } export function useAnnotationRegionCommands({ @@ -25,6 +34,7 @@ export function useAnnotationRegionCommands({ setSelectedZoomId, nextAnnotationIdRef, nextAnnotationZIndexRef, + handleSeek, }: UseAnnotationRegionCommandsParams) { const handleAnnotationAdded = useCallback( (span: Span, trackIndex = 0) => { @@ -115,6 +125,10 @@ export function useAnnotationRegionCommands({ } else if (type === "blur") { updated.content = ""; if (region.blurIntensity === undefined) updated.blurIntensity = 20; + } else if (type === "spotlight") { + updated.content = ""; + if (region.spotlightOpacity === undefined) + updated.spotlightOpacity = DEFAULT_SPOTLIGHT_OPACITY; } return updated; }), @@ -154,6 +168,33 @@ export function useAnnotationRegionCommands({ (id: string, blurColor: string) => updateRegion(id, { blurColor }), [updateRegion], ); + const handleAnnotationSpotlightOpacityChange = useCallback( + (id: string, spotlightOpacity: number) => + updateRegion(id, { spotlightOpacity: clampSpotlightOpacity(spotlightOpacity) }), + [updateRegion], + ); + const handleApplySpotlightOpacityToAll = useCallback( + (spotlightOpacity: number) => { + const value = clampSpotlightOpacity(spotlightOpacity); + setAnnotationRegions((current) => + current.map((region) => + region.type === "spotlight" ? { ...region, spotlightOpacity: value } : region, + ), + ); + }, + [setAnnotationRegions], + ); + const handleAnnotationDisabledChange = useCallback( + (id: string, disabled: boolean) => updateRegion(id, { disabled }), + [updateRegion], + ); + const handleAnnotationFocusStart = useCallback( + (startMs: number) => { + if (!Number.isFinite(startMs)) return; + handleSeek?.(Math.max(0, startMs) / 1000, { pause: true }); + }, + [handleSeek], + ); const handleAnnotationPositionChange = useCallback( (id: string, position: { x: number; y: number }) => updateRegion(id, { position }), [updateRegion], @@ -173,6 +214,10 @@ export function useAnnotationRegionCommands({ handleAnnotationFigureDataChange, handleAnnotationBlurIntensityChange, handleAnnotationBlurColorChange, + handleAnnotationSpotlightOpacityChange, + handleApplySpotlightOpacityToAll, + handleAnnotationDisabledChange, + handleAnnotationFocusStart, handleAnnotationPositionChange, handleAnnotationSizeChange, }; diff --git a/src/components/video-editor/hooks/useTimelineEditingController.ts b/src/components/video-editor/hooks/useTimelineEditingController.ts index 083340f9a..71011178d 100644 --- a/src/components/video-editor/hooks/useTimelineEditingController.ts +++ b/src/components/video-editor/hooks/useTimelineEditingController.ts @@ -204,6 +204,7 @@ export function useTimelineEditingController(input: Input) { setSelectedZoomId: timeline.setSelectedZoomId, nextAnnotationIdRef: input.nextAnnotationIdRef, nextAnnotationZIndexRef: input.nextAnnotationZIndexRef, + handleSeek: playback.handleSeek, }); useEditorGlobalInteractions({ diff --git a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts index 5ea46b498..45b2018c9 100644 --- a/src/components/video-editor/layout/useEditorSettingsPanelProps.ts +++ b/src/components/video-editor/layout/useEditorSettingsPanelProps.ts @@ -223,6 +223,11 @@ export function useEditorSettingsPanelProps(input: Input): ComponentProps { expect(editor.webcam.roundness).toBeCloseTo(4.34, 1); }); }); + +describe("normalizeProjectEditor spotlight annotations", () => { + const baseRegion = { + id: "annotation-1", + startMs: 1000, + endMs: 2000, + content: "", + position: { x: 10, y: 10 }, + size: { width: 30, height: 20 }, + zIndex: 1, + }; + + it("keeps the spotlight type and clamps its opacity", () => { + const editor = normalizeProjectEditor({ + annotationRegions: [ + { ...baseRegion, type: "spotlight", spotlightOpacity: 140, disabled: true }, + ], + } as never); + const [region] = editor.annotationRegions; + expect(region.type).toBe("spotlight"); + expect(region.spotlightOpacity).toBe(100); + expect(region.disabled).toBe(true); + }); + + it("defaults a spotlight without opacity to 50 and leaves it enabled", () => { + const editor = normalizeProjectEditor({ + annotationRegions: [{ ...baseRegion, type: "spotlight" }], + } as never); + const [region] = editor.annotationRegions; + expect(region.spotlightOpacity).toBe(50); + expect(region.disabled).toBeUndefined(); + }); + + it("does not add spotlight fields to other annotation types", () => { + const editor = normalizeProjectEditor({ + annotationRegions: [{ ...baseRegion, type: "blur" }], + } as never); + const [region] = editor.annotationRegions; + expect(region.spotlightOpacity).toBeUndefined(); + expect(region.disabled).toBeUndefined(); + }); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 75154893c..6fd74d100 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -44,6 +44,7 @@ import { DEFAULT_FIGURE_DATA, DEFAULT_PADDING, DEFAULT_PLAYBACK_SPEED, + DEFAULT_SPOTLIGHT_OPACITY, DEFAULT_WEBCAM_MARGIN, DEFAULT_WEBCAM_OVERLAY, DEFAULT_WEBCAM_POSITION_PRESET, @@ -62,6 +63,8 @@ import { DEFAULT_ZOOM_OUT_EASING, DEFAULT_ZOOM_SMOOTHNESS, getDefaultCaptionFontFamily, + MAX_SPOTLIGHT_OPACITY, + MIN_SPOTLIGHT_OPACITY, normalizeCursorClickEffectColor, normalizeCursorClickEffectStyle, type Padding, @@ -564,7 +567,8 @@ export function normalizeProjectEditor(editor: Partial): Pro type: region.type === "image" || region.type === "figure" || - region.type === "blur" + region.type === "blur" || + region.type === "spotlight" ? region.type : "text", content: typeof region.content === "string" ? region.content : "", @@ -624,6 +628,16 @@ export function normalizeProjectEditor(editor: Partial): Pro : 20, blurColor: typeof region.blurColor === "string" ? region.blurColor : undefined, + spotlightOpacity: isFiniteNumber(region.spotlightOpacity) + ? clamp( + region.spotlightOpacity, + MIN_SPOTLIGHT_OPACITY, + MAX_SPOTLIGHT_OPACITY, + ) + : region.type === "spotlight" + ? DEFAULT_SPOTLIGHT_OPACITY + : undefined, + disabled: region.disabled === true ? true : undefined, trackIndex: isFiniteNumber(region.trackIndex) ? Math.max(0, Math.floor(region.trackIndex)) : 0, diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts index 250b213ed..8fb7e547c 100644 --- a/src/components/video-editor/timeline/model/timelineModel.test.ts +++ b/src/components/video-editor/timeline/model/timelineModel.test.ts @@ -109,6 +109,17 @@ describe("timeline model", () => { expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "figure", content: "x" })).toBe( "Annotation", ); + expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "spotlight", content: "" })).toBe( + "Spotlight 50%", + ); + expect( + getAnnotationLabel({ + ...BASE_ANNOTATION, + type: "spotlight", + content: "", + spotlightOpacity: 72.6, + }), + ).toBe("Spotlight 73%"); expect( getAudioLabel({ diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts index e681abfcd..8813b25f6 100644 --- a/src/components/video-editor/timeline/model/timelineModel.ts +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -6,7 +6,7 @@ import type { ClipRegion, ZoomRegion, } from "../../types"; -import { getClipSourceEndMs, getClipSourceStartMs } from "../../types"; +import { DEFAULT_SPOTLIGHT_OPACITY, getClipSourceEndMs, getClipSourceStartMs } from "../../types"; import { CAPTION_ROW_ID, CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants"; import { getAnnotationTrackIndex, @@ -26,6 +26,10 @@ export function getAnnotationLabel(region: AnnotationRegion): string { if (region.type === "image") { return "Image"; } + if (region.type === "spotlight") { + const opacity = Math.round(region.spotlightOpacity ?? DEFAULT_SPOTLIGHT_OPACITY); + return `Spotlight ${opacity}%`; + } return "Annotation"; } diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index d5dc8714f..fac3973f6 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -418,8 +418,12 @@ export function trimsToClips(trims: TrimRegion[], totalDurationMs: number): Clip return clips; } -export type AnnotationType = "text" | "image" | "figure" | "blur"; +export type AnnotationType = "text" | "image" | "figure" | "blur" | "spotlight"; export const BLUR_ANNOTATION_STRENGTH = 20; +/** Default dimming (0-100) applied outside spotlight areas. */ +export const DEFAULT_SPOTLIGHT_OPACITY = 50; +export const MIN_SPOTLIGHT_OPACITY = 0; +export const MAX_SPOTLIGHT_OPACITY = 100; export const BASE_PREVIEW_WIDTH = 1920; export const BASE_PREVIEW_HEIGHT = 1080; @@ -485,6 +489,10 @@ export interface AnnotationRegion { figureData?: FigureData; blurIntensity?: number; blurColor?: string; + /** Spotlight only: how strongly the area outside the spotlight is dimmed (0-100). */ + spotlightOpacity?: number; + /** When true the region stays on the timeline but is not rendered. */ + disabled?: boolean; } export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = { diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index 294c2ce5c..208943555 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "Bitte lade eine JPG-, PNG-, GIF- oder WebP-Bilddatei hoch.", "blurStrength": "Unschärfestärke: {{strength}}", "solidColor": "Einfarbig (Zensur)", - "borderRadius": "Randradius" + "borderRadius": "Randradius", + "spotlight": "Spotlight", + "spotlightOpacity": "Spotlight-Deckkraft: {{opacity}} %", + "resetSpotlightOpacity": "Zurücksetzen", + "applySpotlightOpacityToAll": "Auf alle Spotlights anwenden", + "focusSpotlightStart": "Zum Spotlight-Anfang springen", + "disableSpotlight": "Deaktivieren", + "disableSpotlightDescription": "Deaktiviert wird das Spotlight nicht gerendert, bleibt aber in der Zeitleiste." }, "fontStyles": { "classic": "Klassisch", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 099393ba0..5985c8e56 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "Please upload a JPG, PNG, GIF, or WebP image file.", "blurStrength": "Blur Strength: {{strength}}", "solidColor": "Solid Color (Censorship)", - "borderRadius": "Border Radius" + "borderRadius": "Border Radius", + "spotlight": "Spotlight", + "spotlightOpacity": "Spotlight opacity: {{opacity}}%", + "resetSpotlightOpacity": "Reset", + "applySpotlightOpacityToAll": "Apply to all spotlights", + "focusSpotlightStart": "Jump to spotlight start", + "disableSpotlight": "Disable", + "disableSpotlightDescription": "When disabled, the spotlight is not rendered but stays on the timeline." }, "fontStyles": { diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index c5da834b6..3c4190b9e 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.", "blurStrength": "Fuerza del Desenfoque: {{strength}}", "solidColor": "Color Sólido (Censura)", - "borderRadius": "Radio del Borde" + "borderRadius": "Radio del Borde", + "spotlight": "Foco", + "spotlightOpacity": "Opacidad del foco: {{opacity}}%", + "resetSpotlightOpacity": "Restablecer", + "applySpotlightOpacityToAll": "Aplicar a todos los focos", + "focusSpotlightStart": "Ir al inicio del foco", + "disableSpotlight": "Desactivar", + "disableSpotlightDescription": "Si está desactivado, el foco no se renderiza, pero permanece en la línea de tiempo." }, "fontStyles": { diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 08704aaaa..b5c062f2f 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "Veuillez importer une image JPG, PNG, GIF ou WebP.", "blurStrength": "Intensité du flou : {{strength}}", "solidColor": "Couleur unie (censure)", - "borderRadius": "Rayon de bordure" + "borderRadius": "Rayon de bordure", + "spotlight": "Projecteur", + "spotlightOpacity": "Opacité du projecteur : {{opacity}} %", + "resetSpotlightOpacity": "Réinitialiser", + "applySpotlightOpacityToAll": "Appliquer à tous les projecteurs", + "focusSpotlightStart": "Aller au début du projecteur", + "disableSpotlight": "Désactiver", + "disableSpotlightDescription": "Lorsqu’il est désactivé, le projecteur n’est pas rendu mais reste sur la timeline." }, "fontStyles": { diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 0576b03c3..d86467028 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "Carica un file immagine JPG, PNG, GIF o WebP.", "blurStrength": "Intensità sfocatura: {{strength}}", "solidColor": "Colore pieno (Censura)", - "borderRadius": "Raggio bordo" + "borderRadius": "Raggio bordo", + "spotlight": "Riflettore", + "spotlightOpacity": "Opacità del riflettore: {{opacity}}%", + "resetSpotlightOpacity": "Ripristina", + "applySpotlightOpacityToAll": "Applica a tutti i riflettori", + "focusSpotlightStart": "Vai all’inizio del riflettore", + "disableSpotlight": "Disattiva", + "disableSpotlightDescription": "Se disattivato, il riflettore non viene renderizzato ma resta nella timeline." }, "fontStyles": { diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index ddc411b9e..9547cd090 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -44,7 +44,14 @@ "imageUploadError": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.", "blurStrength": "블러 강도: {{strength}}", "solidColor": "단색 (검열)", - "borderRadius": "테두리 반경" + "borderRadius": "테두리 반경", + "spotlight": "스포트라이트", + "spotlightOpacity": "스포트라이트 불투명도: {{opacity}}%", + "resetSpotlightOpacity": "초기화", + "applySpotlightOpacityToAll": "모든 스포트라이트에 적용", + "focusSpotlightStart": "스포트라이트 시작으로 이동", + "disableSpotlight": "비활성화", + "disableSpotlightDescription": "비활성화하면 스포트라이트가 렌더링되지 않지만 타임라인에는 남아 있습니다." }, "fontStyles": { diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index 9d9376c7b..e4fd3e4ad 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -44,7 +44,14 @@ "imageUploadError": "Upload een JPG-, PNG-, GIF- of WebP-afbeelding.", "blurStrength": "Vervagingssterkte: {{strength}}", "solidColor": "Effen Kleur (Censuur)", - "borderRadius": "Hoekradius" + "borderRadius": "Hoekradius", + "spotlight": "Spotlight", + "spotlightOpacity": "Spotlight-dekking: {{opacity}}%", + "resetSpotlightOpacity": "Herstellen", + "applySpotlightOpacityToAll": "Toepassen op alle spotlights", + "focusSpotlightStart": "Naar begin van spotlight", + "disableSpotlight": "Uitschakelen", + "disableSpotlightDescription": "Uitgeschakeld wordt de spotlight niet weergegeven, maar blijft hij op de tijdlijn staan." }, "fontStyles": { diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 1a3766315..8c8b4ea42 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "Envie um arquivo de imagem JPG, PNG, GIF ou WebP.", "blurStrength": "Intensidade do blur: {{strength}}", "solidColor": "Cor sólida (censura)", - "borderRadius": "Raio da borda" + "borderRadius": "Raio da borda", + "spotlight": "Holofote", + "spotlightOpacity": "Opacidade do holofote: {{opacity}}%", + "resetSpotlightOpacity": "Redefinir", + "applySpotlightOpacityToAll": "Aplicar a todos os holofotes", + "focusSpotlightStart": "Ir para o início do holofote", + "disableSpotlight": "Desativar", + "disableSpotlightDescription": "Quando desativado, o holofote não é renderizado, mas permanece na linha do tempo." }, "fontStyles": { diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 56c62e1c7..619bb7faa 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "Загрузите файл JPG, PNG, GIF, или WebP.", "blurStrength": "Сила размытия: {{strength}}", "solidColor": "Сплошной цвет (цензура)", - "borderRadius": "Скругление углов" + "borderRadius": "Скругление углов", + "spotlight": "Прожектор", + "spotlightOpacity": "Непрозрачность прожектора: {{opacity}}%", + "resetSpotlightOpacity": "Сбросить", + "applySpotlightOpacityToAll": "Применить ко всем прожекторам", + "focusSpotlightStart": "Перейти к началу прожектора", + "disableSpotlight": "Отключить", + "disableSpotlightDescription": "Отключённый прожектор не отображается, но остаётся на таймлайне." }, "fontStyles": { diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index bb799ed11..ea42e5e93 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "请上传 JPG、PNG、GIF 或 WebP 图片文件。", "blurStrength": "模糊强度: {{strength}}", "solidColor": "纯色 (审查)", - "borderRadius": "边框半径" + "borderRadius": "边框半径", + "spotlight": "聚光灯", + "spotlightOpacity": "聚光灯透明度:{{opacity}}%", + "resetSpotlightOpacity": "重置", + "applySpotlightOpacityToAll": "应用到所有聚光灯", + "focusSpotlightStart": "跳转到聚光灯起点", + "disableSpotlight": "禁用", + "disableSpotlightDescription": "禁用后不会渲染聚光灯,但仍保留在时间轴上。" }, "fontStyles": { diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 0332aed16..4d92089f1 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -43,7 +43,14 @@ "imageUploadError": "請上傳 JPG、PNG、GIF 或 WebP 圖片檔案。", "blurStrength": "模糊強度: {{strength}}", "solidColor": "純色 (審查)", - "borderRadius": "邊框半徑" + "borderRadius": "邊框半徑", + "spotlight": "聚光燈", + "spotlightOpacity": "聚光燈透明度:{{opacity}}%", + "resetSpotlightOpacity": "重設", + "applySpotlightOpacityToAll": "套用到所有聚光燈", + "focusSpotlightStart": "跳至聚光燈起點", + "disableSpotlight": "停用", + "disableSpotlightDescription": "停用後不會渲染聚光燈,但仍保留在時間軸上。" }, "fontStyles": { diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts index d6c6ce991..78b206dc8 100644 --- a/src/lib/exporter/annotationRenderer.ts +++ b/src/lib/exporter/annotationRenderer.ts @@ -3,6 +3,13 @@ import { type ArrowDirection, BLUR_ANNOTATION_STRENGTH, } from "@/components/video-editor/types"; +import { + getActiveSpotlights, + getSpotlightDimAlpha, + getSpotlightHoleStrengths, + paintSpotlightMask, + SPOTLIGHT_CORNER_RADIUS, +} from "@/lib/spotlight/spotlightMask"; export interface AnnotationRenderAssets { imageCache: Map; @@ -48,6 +55,65 @@ function getBlurBufferCanvas(): HTMLCanvasElement | null { return blurBufferCanvas; } +let spotlightBufferCanvas: HTMLCanvasElement | null = null; + +function getSpotlightBufferCanvas(): HTMLCanvasElement | null { + if (typeof document === "undefined") return null; + if (!spotlightBufferCanvas) { + spotlightBufferCanvas = document.createElement("canvas"); + } + return spotlightBufferCanvas; +} + +function renderSpotlightMask( + ctx: CanvasRenderingContext2D, + annotations: AnnotationRegion[], + currentTimeMs: number, + annotationRect: AnnotationCoordinateRect, + scaleFactor: number, + sceneTransform: AnnotationSceneTransform | undefined, + videoCornerRadius: number, +): void { + const spotlights = getActiveSpotlights(annotations, currentTimeMs); + if (spotlights.length === 0) return; + + const alpha = getSpotlightDimAlpha(spotlights, currentTimeMs); + const buffer = getSpotlightBufferCanvas(); + const bufferCtx = buffer?.getContext("2d"); + if (!buffer || !bufferCtx || alpha <= 0) return; + + if (buffer.width !== ctx.canvas.width || buffer.height !== ctx.canvas.height) { + buffer.width = ctx.canvas.width; + buffer.height = ctx.canvas.height; + } + bufferCtx.clearRect(0, 0, buffer.width, buffer.height); + + const sceneScale = sceneTransform?.scale ?? 1; + const strengths = getSpotlightHoleStrengths(spotlights, currentTimeMs); + const painted = paintSpotlightMask(bufferCtx, { + area: transformAnnotationRect(annotationRect, sceneTransform), + areaRadius: videoCornerRadius * sceneScale, + holes: spotlights.map((spotlight, index) => ({ + ...transformAnnotationRect( + { + x: annotationRect.x + (spotlight.position.x / 100) * annotationRect.width, + y: annotationRect.y + (spotlight.position.y / 100) * annotationRect.height, + width: (spotlight.size.width / 100) * annotationRect.width, + height: (spotlight.size.height / 100) * annotationRect.height, + }, + sceneTransform, + ), + strength: strengths[index], + })), + holeRadius: SPOTLIGHT_CORNER_RADIUS * scaleFactor * sceneScale, + alpha, + }); + + if (painted) { + ctx.drawImage(buffer, 0, 0); + } +} + function getAnnotationImageContent(annotation: AnnotationRegion): string | null { const source = annotation.imageContent || annotation.content; if (!source || !source.startsWith("data:image")) { @@ -365,9 +431,10 @@ export async function renderAnnotations( assets?: AnnotationRenderAssets, sceneTransform?: AnnotationSceneTransform, coordinateRect?: AnnotationCoordinateRect, + videoCornerRadius = 0, ): Promise { const activeAnnotations = annotations.filter( - (ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs, + (ann) => !ann.disabled && currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs, ); const sortedAnnotations = [...activeAnnotations].sort((a, b) => a.zIndex - b.zIndex); @@ -378,6 +445,17 @@ export async function renderAnnotations( height: canvasHeight, }; + // Spotlights dim the scene underneath every other annotation. + renderSpotlightMask( + ctx, + activeAnnotations, + currentTimeMs, + annotationRect, + scaleFactor, + sceneTransform, + videoCornerRadius, + ); + for (const annotation of sortedAnnotations) { const rect = transformAnnotationRect( { @@ -457,6 +535,10 @@ export async function renderAnnotations( ctx.restore(); break; } + + case "spotlight": + // Painted once for all active spotlights by renderSpotlightMask. + break; } } } @@ -512,6 +594,9 @@ export async function renderAnnotationToCanvas( // Blur annotations must sample already-rendered scene pixels, // so they cannot be rasterized as standalone sprites. return null; + case "spotlight": + // Spotlights dim the whole scene around them, so they are composited on canvas. + return null; } return canvas; diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 640bd39aa..3ae63878d 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -76,7 +76,6 @@ import { renderCaptions } from "./captionRenderer"; import { ForwardFrameSource } from "./forwardFrameSource"; import { resolveMediaElementSource } from "./localMediaSource"; - interface FrameRenderConfig { timelineEffects?: boolean; width: number; @@ -1510,6 +1509,13 @@ export class FrameRenderer { y: this.animationState.y, }, this.layoutCache?.maskRect, + this.layoutCache?.maskRect + ? scalePreviewBorderRadius( + this.layoutCache.maskRect.width, + this.layoutCache.maskRect.height, + this.config.borderRadius ?? 0, + ) + : 0, ); } diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 629f25210..76b72de9d 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -1419,15 +1419,30 @@ export class FrameRenderer { return (this.config.width / previewWidth + this.config.height / previewHeight) / 2; } + /** + * Blur and spotlight annotations sample or dim already-rendered scene pixels, so frames + * that contain them are composited on a 2D canvas instead of the sprite layer. + */ private hasActiveBlurAnnotations(timeMs: number): boolean { return (this.config.annotationRegions ?? []).some( (annotation) => - annotation.type === "blur" && + (annotation.type === "blur" || annotation.type === "spotlight") && + !annotation.disabled && timeMs >= annotation.startMs && timeMs <= annotation.endMs, ); } + private getVideoCornerRadius(): number { + const maskRect = this.layoutCache?.maskRect; + if (!maskRect) return 0; + return scalePreviewBorderRadius( + maskRect.width, + maskRect.height, + this.config.borderRadius ?? 0, + ); + } + private ensureExportCompositeCanvas(): ExportCompositeCanvasState | null { const targetWidth = Math.max(1, Math.ceil(this.config.width)); const targetHeight = Math.max(1, Math.ceil(this.config.height)); @@ -1507,6 +1522,7 @@ export class FrameRenderer { y: this.animationState.y, }, this.layoutCache?.maskRect, + this.getVideoCornerRadius(), ); this.drawCaptionOverlay(context); @@ -1568,6 +1584,7 @@ export class FrameRenderer { private updateAnnotationLayer(currentTimeMs: number): void { for (const entry of this.annotationSprites) { entry.sprite.visible = + !entry.annotation.disabled && currentTimeMs >= entry.annotation.startMs && currentTimeMs <= entry.annotation.endMs; } diff --git a/src/lib/spotlight/spotlightMask.test.ts b/src/lib/spotlight/spotlightMask.test.ts new file mode 100644 index 000000000..0721aed97 --- /dev/null +++ b/src/lib/spotlight/spotlightMask.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AnnotationRegion } from "@/components/video-editor/types"; +import { + getActiveSpotlights, + getSpotlightDimAlpha, + getSpotlightFadeFactor, + getSpotlightHoleStrengths, + paintSpotlightMask, + SPOTLIGHT_FADE_MS, +} from "./spotlightMask"; + +function region(overrides: Partial): AnnotationRegion { + return { + id: "s1", + startMs: 1000, + endMs: 3000, + type: "spotlight", + content: "", + position: { x: 10, y: 10 }, + size: { width: 20, height: 20 }, + style: {} as AnnotationRegion["style"], + zIndex: 1, + ...overrides, + }; +} + +describe("getSpotlightFadeFactor", () => { + it("fades in, holds and fades out", () => { + const r = region({}); + expect(getSpotlightFadeFactor(r, 999)).toBe(0); + expect(getSpotlightFadeFactor(r, 1000)).toBe(0); + expect(getSpotlightFadeFactor(r, 1000 + SPOTLIGHT_FADE_MS / 2)).toBeCloseTo(0.5); + expect(getSpotlightFadeFactor(r, 2000)).toBe(1); + expect(getSpotlightFadeFactor(r, 3000 - SPOTLIGHT_FADE_MS / 2)).toBeCloseTo(0.5); + expect(getSpotlightFadeFactor(r, 3001)).toBe(0); + }); + + it("shortens the fade for very short regions", () => { + const r = region({ startMs: 0, endMs: 200 }); + expect(getSpotlightFadeFactor(r, 100)).toBe(1); + }); +}); + +describe("getActiveSpotlights", () => { + it("returns only enabled spotlights inside their time range", () => { + const annotations = [ + region({ id: "a" }), + region({ id: "b", disabled: true }), + region({ id: "c", type: "blur" }), + region({ id: "d", startMs: 5000, endMs: 6000 }), + ]; + expect(getActiveSpotlights(annotations, 2000).map((r) => r.id)).toEqual(["a"]); + }); +}); + +describe("getSpotlightDimAlpha", () => { + it("uses the strongest overlapping spotlight", () => { + const alpha = getSpotlightDimAlpha( + [region({ spotlightOpacity: 30 }), region({ id: "s2", spotlightOpacity: 80 })], + 2000, + ); + expect(alpha).toBeCloseTo(0.8); + }); + + it("defaults to 50% and applies the fade", () => { + expect(getSpotlightDimAlpha([region({})], 2000)).toBeCloseTo(0.5); + expect(getSpotlightDimAlpha([region({})], 1000 + SPOTLIGHT_FADE_MS / 2)).toBeCloseTo(0.25); + }); +}); + +describe("getSpotlightHoleStrengths", () => { + it("keeps a lone spotlight fully cut out while it fades", () => { + const r = region({}); + expect(getSpotlightHoleStrengths([r], 1000 + SPOTLIGHT_FADE_MS / 2)).toEqual([1]); + }); + + it("fades in a spotlight that starts while another is fully visible", () => { + const visible = region({ id: "a", startMs: 0, endMs: 5000 }); + const fadingIn = region({ id: "b", startMs: 2000, endMs: 5000 }); + const [a, b] = getSpotlightHoleStrengths([visible, fadingIn], 2000 + SPOTLIGHT_FADE_MS / 2); + expect(a).toBe(1); + expect(b).toBeCloseTo(0.5); + }); + + it("fades out a spotlight that ends while another stays visible", () => { + const fadingOut = region({ id: "a", startMs: 0, endMs: 3000 }); + const visible = region({ id: "b", startMs: 1000, endMs: 6000 }); + const [a, b] = getSpotlightHoleStrengths( + [fadingOut, visible], + 3000 - SPOTLIGHT_FADE_MS / 4, + ); + expect(a).toBeCloseTo(0.25); + expect(b).toBe(1); + }); +}); + +describe("paintSpotlightMask", () => { + function mockContext() { + return { + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + roundRect: vi.fn(), + fill: vi.fn(), + fillStyle: "", + globalCompositeOperation: "source-over", + globalAlpha: 1, + } as unknown as CanvasRenderingContext2D & { roundRect: ReturnType }; + } + + it("dims the area once and cuts every hole in a single path", () => { + const ctx = mockContext(); + const painted = paintSpotlightMask(ctx, { + area: { x: 0, y: 0, width: 100, height: 100 }, + areaRadius: 10, + holes: [ + { x: 10, y: 10, width: 20, height: 20 }, + { x: 20, y: 20, width: 20, height: 20 }, + ], + holeRadius: 50, + alpha: 0.5, + }); + expect(painted).toBe(true); + expect(ctx.fill).toHaveBeenCalledTimes(2); + expect(ctx.roundRect).toHaveBeenCalledTimes(3); + // Hole radius is clamped to half the hole size. + expect(ctx.roundRect).toHaveBeenLastCalledWith(20, 20, 20, 20, 10); + }); + + it("skips painting when nothing is visible", () => { + const ctx = mockContext(); + expect( + paintSpotlightMask(ctx, { + area: { x: 0, y: 0, width: 100, height: 100 }, + areaRadius: 0, + holes: [{ x: 0, y: 0, width: 10, height: 10 }], + holeRadius: 0, + alpha: 0, + }), + ).toBe(false); + expect(ctx.fill).not.toHaveBeenCalled(); + }); + + it("cuts partially faded holes with their own strength", () => { + const ctx = mockContext(); + const alphas: number[] = []; + (ctx.fill as ReturnType).mockImplementation(() => { + alphas.push(ctx.globalAlpha); + }); + paintSpotlightMask(ctx, { + area: { x: 0, y: 0, width: 100, height: 100 }, + areaRadius: 0, + holes: [ + { x: 0, y: 0, width: 10, height: 10 }, + { x: 50, y: 50, width: 10, height: 10, strength: 0.4 }, + ], + holeRadius: 0, + alpha: 0.5, + }); + // Dim layer, merged full-strength holes, then the partial hole. + expect(alphas).toEqual([1, 1, 0.4]); + }); +}); diff --git a/src/lib/spotlight/spotlightMask.ts b/src/lib/spotlight/spotlightMask.ts new file mode 100644 index 000000000..d77993c8a --- /dev/null +++ b/src/lib/spotlight/spotlightMask.ts @@ -0,0 +1,154 @@ +import { + type AnnotationRegion, + DEFAULT_SPOTLIGHT_OPACITY, + MAX_SPOTLIGHT_OPACITY, + MIN_SPOTLIGHT_OPACITY, +} from "@/components/video-editor/types"; + +/** Duration of the spotlight fade in and fade out, in milliseconds. */ +export const SPOTLIGHT_FADE_MS = 300; +/** Corner radius of a spotlight hole, in base preview pixels (1920px wide). */ +export const SPOTLIGHT_CORNER_RADIUS = 12; + +export interface SpotlightRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface SpotlightHole extends SpotlightRect { + /** How fully the hole is cut out (0-1). Defaults to 1. */ + strength?: number; +} + +export interface SpotlightMaskPaintOptions { + /** Area that gets dimmed, usually the video rect in canvas coordinates. */ + area: SpotlightRect; + /** Corner radius of the dimmed area so it follows the video's rounded corners. */ + areaRadius: number; + /** + * Areas kept bright. Full-strength holes merge instead of stacking; partial holes + * are cut with their strength so staggered fades stay smooth. + */ + holes: SpotlightHole[]; + holeRadius: number; + /** Dimming alpha between 0 and 1. */ + alpha: number; +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +/** Fade multiplier (0-1) for a region at the given time, including fade in and out. */ +export function getSpotlightFadeFactor( + region: Pick, + timeMs: number, +): number { + const { startMs, endMs } = region; + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return 0; + if (timeMs < startMs || timeMs > endMs) return 0; + + const fadeMs = Math.min(SPOTLIGHT_FADE_MS, (endMs - startMs) / 2); + if (fadeMs <= 0) return 1; + + return clamp01(Math.min((timeMs - startMs) / fadeMs, (endMs - timeMs) / fadeMs, 1)); +} + +/** Spotlight regions that should be painted at the given time. Disabled ones are skipped. */ +export function getActiveSpotlights( + annotations: readonly AnnotationRegion[], + timeMs: number, +): AnnotationRegion[] { + return annotations.filter( + (annotation) => + annotation.type === "spotlight" && + !annotation.disabled && + timeMs >= annotation.startMs && + timeMs <= annotation.endMs, + ); +} + +/** Dimming alpha for overlapping spotlights: the strongest active spotlight wins. */ +export function getSpotlightDimAlpha( + spotlights: readonly AnnotationRegion[], + timeMs: number, +): number { + let alpha = 0; + for (const spotlight of spotlights) { + const opacity = Math.min( + MAX_SPOTLIGHT_OPACITY, + Math.max( + MIN_SPOTLIGHT_OPACITY, + spotlight.spotlightOpacity ?? DEFAULT_SPOTLIGHT_OPACITY, + ), + ); + alpha = Math.max(alpha, (opacity / 100) * getSpotlightFadeFactor(spotlight, timeMs)); + } + return clamp01(alpha); +} + +/** + * Cut-out strength (0-1) for each spotlight, in the same order as the input. + * The dim layer follows the strongest fade, so each hole is weighted by its own + * fade relative to that. A lone spotlight, or spotlights fading together, stay at 1. + */ +export function getSpotlightHoleStrengths( + spotlights: readonly AnnotationRegion[], + timeMs: number, +): number[] { + const fades = spotlights.map((spotlight) => getSpotlightFadeFactor(spotlight, timeMs)); + const maxFade = Math.max(0, ...fades); + if (maxFade <= 0) return fades.map(() => 0); + return fades.map((fade) => clamp01(fade / maxFade)); +} + +function clampRadius(rect: SpotlightRect, radius: number): number { + return Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2)); +} + +/** + * Paint a dim layer with transparent holes. The target context must be a dedicated + * layer (not the video canvas), because holes are cut with destination-out. + */ +export function paintSpotlightMask( + ctx: CanvasRenderingContext2D, + { area, areaRadius, holes, holeRadius, alpha }: SpotlightMaskPaintOptions, +): boolean { + const safeAlpha = clamp01(alpha); + const validHoles = holes.filter((hole) => hole.width > 0 && hole.height > 0); + if (safeAlpha <= 0 || validHoles.length === 0 || area.width <= 0 || area.height <= 0) { + return false; + } + + ctx.save(); + ctx.fillStyle = `rgba(0, 0, 0, ${safeAlpha})`; + ctx.beginPath(); + ctx.roundRect(area.x, area.y, area.width, area.height, clampRadius(area, areaRadius)); + ctx.fill(); + + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#000"; + + const fullHoles = validHoles.filter((hole) => clamp01(hole.strength ?? 1) >= 1); + if (fullHoles.length > 0) { + ctx.beginPath(); + for (const hole of fullHoles) { + ctx.roundRect(hole.x, hole.y, hole.width, hole.height, clampRadius(hole, holeRadius)); + } + ctx.fill(); + } + + for (const hole of validHoles) { + const strength = clamp01(hole.strength ?? 1); + if (strength <= 0 || strength >= 1) continue; + ctx.globalAlpha = strength; + ctx.beginPath(); + ctx.roundRect(hole.x, hole.y, hole.width, hole.height, clampRadius(hole, holeRadius)); + ctx.fill(); + } + ctx.restore(); + return true; +}