Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/components/video-editor/AnnotationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<div
className={cn(
"h-full w-full",
annotation.disabled && "border border-dashed border-white/60",
)}
style={{
borderRadius: `${SPOTLIGHT_CORNER_RADIUS * blurScaleFactor}px`,
}}
/>
);

default:
return null;
}
Expand Down Expand Up @@ -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",
)}
>
Expand Down
111 changes: 109 additions & 2 deletions src/components/video-editor/AnnotationSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AlignRight,
TextB as Bold,
CaretDown as ChevronDown,
Flashlight,
ImageSquare as ImageIcon,
Info,
TextItalic as Italic,
Expand All @@ -26,14 +27,21 @@ 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";
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;
Expand All @@ -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;
}

Expand All @@ -67,6 +79,10 @@ export function AnnotationSettingsPanel({
onFigureDataChange,
onBlurIntensityChange,
onBlurColorChange,
onSpotlightOpacityChange,
onApplySpotlightOpacityToAll,
onDisabledChange,
onFocusStart,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT("editor");
Expand Down Expand Up @@ -157,7 +173,7 @@ export function AnnotationSettingsPanel({
onValueChange={(value) => onTypeChange(value as AnnotationType)}
className="mb-6"
>
<TabsList className="mb-4 bg-foreground/5 border border-foreground/5 p-1 w-full grid grid-cols-4 h-auto rounded-xl">
<TabsList className="mb-4 bg-foreground/5 border border-foreground/5 p-1 w-full grid grid-cols-5 h-auto rounded-xl">
<TabsTrigger
value="text"
className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-muted-foreground py-2 rounded-lg transition-all gap-2"
Expand Down Expand Up @@ -198,6 +214,13 @@ export function AnnotationSettingsPanel({
<SquareDashed className="w-4 h-4" />
{t("annotations.blur")}
</TabsTrigger>
<TabsTrigger
value="spotlight"
className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-muted-foreground py-2 rounded-lg transition-all gap-2"
>
<Flashlight className="w-4 h-4" />
{t("annotations.spotlight")}
</TabsTrigger>
</TabsList>

{/* Text Content */}
Expand Down Expand Up @@ -660,6 +683,90 @@ export function AnnotationSettingsPanel({
</div>
</TabsContent>

<TabsContent value="spotlight" className="mt-0 space-y-4">
{onFocusStart && (
<Button
variant="outline"
size="sm"
onClick={onFocusStart}
className="w-full bg-foreground/5 border-foreground/10 text-foreground hover:bg-foreground/10"
>
{t("annotations.focusSpotlightStart")}
</Button>
)}

<div className="p-4 bg-foreground/5 rounded-xl border border-foreground/10 space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">
{t("annotations.spotlightOpacity", undefined, {
opacity: Math.round(
annotation.spotlightOpacity ??
DEFAULT_SPOTLIGHT_OPACITY,
),
})}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
disabled={
(annotation.spotlightOpacity ??
DEFAULT_SPOTLIGHT_OPACITY) ===
DEFAULT_SPOTLIGHT_OPACITY
}
onClick={() =>
onSpotlightOpacityChange?.(DEFAULT_SPOTLIGHT_OPACITY)
}
>
{t("annotations.resetSpotlightOpacity")}
</Button>
</div>
<Slider
value={[
annotation.spotlightOpacity ?? DEFAULT_SPOTLIGHT_OPACITY,
]}
onValueChange={([value]) => onSpotlightOpacityChange?.(value)}
min={0}
max={100}
step={1}
className="w-full"
/>
{onApplySpotlightOpacityToAll && (
<Button
variant="outline"
size="sm"
className="w-full bg-foreground/5 border-foreground/10 text-foreground hover:bg-foreground/10"
onClick={() =>
onApplySpotlightOpacityToAll(
annotation.spotlightOpacity ??
DEFAULT_SPOTLIGHT_OPACITY,
)
}
>
{t("annotations.applySpotlightOpacityToAll")}
</Button>
)}
</div>

{onDisabledChange && (
<div className="p-4 bg-foreground/5 rounded-xl border border-foreground/10 flex items-start justify-between gap-3">
<div className="space-y-1">
<span className="text-xs font-medium text-foreground block">
{t("annotations.disableSpotlight")}
</span>
<span className="text-xs text-muted-foreground block">
{t("annotations.disableSpotlightDescription")}
</span>
</div>
<Switch
checked={annotation.disabled === true}
onCheckedChange={onDisabledChange}
aria-label={t("annotations.disableSpotlight")}
/>
</div>
)}
</TabsContent>

<TabsContent value="blur" className="mt-0 space-y-4">
<div className="p-4 bg-foreground/5 rounded-xl border border-foreground/10 flex flex-col items-center">
<div className="w-full space-y-3">
Expand Down
25 changes: 25 additions & 0 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1074,6 +1078,10 @@ export function SettingsPanel({
onAnnotationFigureDataChange,
onAnnotationBlurIntensityChange,
onAnnotationBlurColorChange,
onAnnotationSpotlightOpacityChange,
onApplySpotlightOpacityToAll,
onAnnotationDisabledChange,
onAnnotationFocusStart,
onAnnotationDelete,
autoCaptions = [],
autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS,
Expand Down Expand Up @@ -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)}
/>
);
Expand Down
84 changes: 84 additions & 0 deletions src/components/video-editor/SpotlightMaskOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
aria-hidden="true"
className="absolute left-0 top-0"
style={{ width, height, pointerEvents: "none" }}
/>
);
}
Loading