+
+
+ {isSelected && shape !== "freehand" && (
+
+ )}
+
+ {isSelected && shape === "freehand" && freehandPath && (
+
+ )}
+ {isFreehandSelected && (
+
+ )}
+
+ );
+ }
+
default:
return null;
}
@@ -149,18 +378,23 @@ export function AnnotationOverlay({
}}
bounds="parent"
className={cn(
- "cursor-move transition-all",
- isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
+ "cursor-move",
+ isSelected &&
+ annotation.type !== "blur" &&
+ "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? "auto" : "none",
- border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
- backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
- boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
+ border:
+ isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
+ backgroundColor:
+ isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
+ boxShadow:
+ isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
}}
- enableResizing={isSelected}
- disableDragging={!isSelected}
+ enableResizing={isSelected && !isSelectedFreehandBlur}
+ disableDragging={!isSelected || isSelectedFreehandBlur}
resizeHandleStyles={{
topLeft: {
width: "12px",
@@ -206,11 +440,13 @@ export function AnnotationOverlay({
>
{renderContent()}
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index b289392..f5c2a0b 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -32,7 +32,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
import { cn } from "@/lib/utils";
import { AddCustomFontDialog } from "./AddCustomFontDialog";
import { getArrowComponent } from "./ArrowSvgs";
-import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
+import {
+ type AnnotationRegion,
+ type AnnotationType,
+ type ArrowDirection,
+ type FigureData,
+} from "./types";
interface AnnotationSettingsPanelProps {
annotation: AnnotationRegion;
diff --git a/src/components/video-editor/BlurSettingsPanel.tsx b/src/components/video-editor/BlurSettingsPanel.tsx
new file mode 100644
index 0000000..382cd80
--- /dev/null
+++ b/src/components/video-editor/BlurSettingsPanel.tsx
@@ -0,0 +1,142 @@
+import { Info, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Slider } from "@/components/ui/slider";
+import { useScopedT } from "@/contexts/I18nContext";
+import { cn } from "@/lib/utils";
+import {
+ type AnnotationRegion,
+ type BlurData,
+ type BlurShape,
+ DEFAULT_BLUR_DATA,
+ MAX_BLUR_INTENSITY,
+ MIN_BLUR_INTENSITY,
+} from "./types";
+
+interface BlurSettingsPanelProps {
+ blurRegion: AnnotationRegion;
+ onBlurDataChange: (blurData: BlurData) => void;
+ onBlurDataCommit?: () => void;
+ onDelete: () => void;
+}
+
+export function BlurSettingsPanel({
+ blurRegion,
+ onBlurDataChange,
+ onBlurDataCommit,
+ onDelete,
+}: BlurSettingsPanelProps) {
+ const t = useScopedT("settings");
+
+ const blurShapeOptions: Array<{ value: BlurShape; labelKey: string }> = [
+ { value: "rectangle", labelKey: "blurShapeRectangle" },
+ { value: "oval", labelKey: "blurShapeOval" },
+ ];
+
+ return (
+
+
+
+ {t("annotation.blurShape")}
+
+ {t("annotation.active")}
+
+
+
+
+ {blurShapeOptions.map((shape) => {
+ const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
+ const isActive = activeShape === shape.value;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ {t("annotation.blurIntensity")}
+
+
+ {Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
+
+
+
{
+ onBlurDataChange({
+ ...DEFAULT_BLUR_DATA,
+ ...blurRegion.blurData,
+ intensity: values[0],
+ });
+ }}
+ onValueCommit={() => onBlurDataCommit?.()}
+ min={MIN_BLUR_INTENSITY}
+ max={MAX_BLUR_INTENSITY}
+ step={1}
+ className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+
+
+
+
+
+ {t("annotation.shortcutsAndTips")}
+
+
+ - {t("annotation.tipMovePlayhead")}
+
+
+
+
+ );
+}
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 07922f3..4f63a14 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -42,11 +42,13 @@ import { cn } from "@/lib/utils";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import { getTestId } from "@/utils/getTestId";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
+import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
AnnotationType,
+ BlurData,
CropRegion,
FigureData,
PlaybackSpeed,
@@ -209,6 +211,11 @@ interface SettingsPanelProps {
onAnnotationStyleChange?: (id: string, style: Partial
) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationDelete?: (id: string) => void;
+ selectedBlurId?: string | null;
+ blurRegions?: AnnotationRegion[];
+ onBlurDataChange?: (id: string, blurData: BlurData) => void;
+ onBlurDataCommit?: () => void;
+ onBlurDelete?: (id: string) => void;
selectedSpeedId?: string | null;
selectedSpeedValue?: PlaybackSpeed | null;
onSpeedChange?: (speed: PlaybackSpeed) => void;
@@ -295,6 +302,11 @@ export function SettingsPanel({
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
+ selectedBlurId,
+ blurRegions = [],
+ onBlurDataChange,
+ onBlurDataCommit,
+ onBlurDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -355,6 +367,7 @@ export function SettingsPanel({
const cropSnapshotRef = useRef(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");
+ const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;
@@ -533,6 +546,9 @@ export function SettingsPanel({
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
: null;
+ const selectedBlur = selectedBlurId
+ ? blurRegions.find((region) => region.id === selectedBlurId)
+ : null;
// If an annotation is selected, show annotation settings instead
if (
@@ -558,6 +574,17 @@ export function SettingsPanel({
);
}
+ if (selectedBlur && onBlurDataChange && onBlurDelete) {
+ return (
+ onBlurDataChange(selectedBlur.id, blurData)}
+ onBlurDataCommit={onBlurDataCommit}
+ onDelete={() => onBlurDelete(selectedBlur.id)}
+ />
+ );
+ }
+
return (
@@ -799,15 +826,17 @@ export function SettingsPanel({
- {WEBCAM_LAYOUT_PRESETS.filter(
- (preset) =>
- preset.value === "picture-in-picture" ||
- isPortraitAspectRatio(aspectRatio),
- ).map((preset) => (
+ {WEBCAM_LAYOUT_PRESETS.filter((preset) => {
+ if (preset.value === "picture-in-picture") return true;
+ if (preset.value === "vertical-stack") return isPortraitCanvas;
+ return !isPortraitCanvas;
+ }).map((preset) => (
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
- : t("layout.verticalStack")}
+ : preset.value === "vertical-stack"
+ ? t("layout.verticalStack")
+ : t("layout.dualFrame")}
))}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index f07fae9..b78d5b2 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -54,11 +54,13 @@ import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
type AnnotationRegion,
+ type BlurData,
type CursorTelemetryPoint,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_BLUR_DATA,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_ZOOM_DEPTH,
@@ -123,6 +125,7 @@ export default function VideoEditor() {
const [selectedTrimId, setSelectedTrimId] = useState
(null);
const [selectedSpeedId, setSelectedSpeedId] = useState(null);
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null);
+ const [selectedBlurId, setSelectedBlurId] = useState(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(null);
const [exportError, setExportError] = useState(null);
@@ -158,6 +161,15 @@ export default function VideoEditor() {
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef(null);
+ const annotationOnlyRegions = useMemo(
+ () => annotationRegions.filter((region) => region.type !== "blur"),
+ [annotationRegions],
+ );
+ const blurRegions = useMemo(
+ () => annotationRegions.filter((region) => region.type === "blur"),
+ [annotationRegions],
+ );
+
const currentProjectMedia = useMemo(() => {
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!screenVideoPath) {
@@ -230,6 +242,7 @@ export default function VideoEditor() {
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
nextZoomIdRef.current = deriveNextId(
"zoom",
@@ -627,7 +640,11 @@ export default function VideoEditor() {
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
- if (id) setSelectedTrimId(null);
+ if (id) {
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
+ }
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
@@ -635,6 +652,7 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
}
}, []);
@@ -643,6 +661,17 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
+ setSelectedBlurId(null);
+ }
+ }, []);
+
+ const handleSelectBlur = useCallback((id: string | null) => {
+ setSelectedBlurId(id);
+ if (id) {
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ setSelectedSpeedId(null);
}
}, []);
@@ -660,6 +689,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -678,6 +708,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -694,6 +725,7 @@ export default function VideoEditor() {
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -804,6 +836,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
}
}, []);
@@ -823,6 +856,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedBlurId(null);
},
[pushState],
);
@@ -889,6 +923,35 @@ export default function VideoEditor() {
setSelectedAnnotationId(id);
setSelectedZoomId(null);
setSelectedTrimId(null);
+ setSelectedBlurId(null);
+ },
+ [pushState],
+ );
+
+ const handleBlurAdded = useCallback(
+ (span: Span) => {
+ const id = `annotation-${nextAnnotationIdRef.current++}`;
+ const zIndex = nextAnnotationZIndexRef.current++;
+ const newRegion: AnnotationRegion = {
+ id,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ type: "blur",
+ content: "",
+ position: { ...DEFAULT_ANNOTATION_POSITION },
+ size: { ...DEFAULT_ANNOTATION_SIZE },
+ style: { ...DEFAULT_ANNOTATION_STYLE },
+ zIndex,
+ blurData: { ...DEFAULT_BLUR_DATA },
+ };
+ pushState((prev) => ({
+ annotationRegions: [...prev.annotationRegions, newRegion],
+ }));
+ setSelectedBlurId(id);
+ setSelectedAnnotationId(null);
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedSpeedId(null);
},
[pushState],
);
@@ -931,8 +994,11 @@ export default function VideoEditor() {
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
+ if (selectedBlurId === id) {
+ setSelectedBlurId(null);
+ }
},
- [selectedAnnotationId, pushState],
+ [selectedAnnotationId, selectedBlurId, pushState],
);
const handleAnnotationContentChange = useCallback(
@@ -967,12 +1033,26 @@ export default function VideoEditor() {
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
+ } else if (type === "blur") {
+ updatedRegion.content = "";
+ if (!region.blurData) {
+ updatedRegion.blurData = { ...DEFAULT_BLUR_DATA };
+ }
}
return updatedRegion;
}),
}));
+
+ if (type === "blur" && selectedAnnotationId === id) {
+ setSelectedAnnotationId(null);
+ setSelectedBlurId(id);
+ setSelectedSpeedId(null);
+ } else if (type !== "blur" && selectedBlurId === id) {
+ setSelectedBlurId(null);
+ setSelectedAnnotationId(id);
+ }
},
- [pushState],
+ [pushState, selectedAnnotationId, selectedBlurId],
);
const handleAnnotationStyleChange = useCallback(
@@ -997,6 +1077,51 @@ export default function VideoEditor() {
[pushState],
);
+ const handleBlurDataPreviewChange = useCallback(
+ (id: string, blurData: BlurData) => {
+ updateState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
+ region.id === id
+ ? {
+ ...region,
+ blurData,
+ // Freehand drawing area is the full video surface.
+ ...(blurData.shape === "freehand"
+ ? {
+ position: { x: 0, y: 0 },
+ size: { width: 100, height: 100 },
+ }
+ : {}),
+ }
+ : region,
+ ),
+ }));
+ },
+ [updateState],
+ );
+
+ const handleBlurDataPanelChange = useCallback(
+ (id: string, blurData: BlurData) => {
+ pushState((prev) => ({
+ annotationRegions: prev.annotationRegions.map((region) =>
+ region.id === id
+ ? {
+ ...region,
+ blurData,
+ ...(blurData.shape === "freehand"
+ ? {
+ position: { x: 0, y: 0 },
+ size: { width: 100, height: 100 },
+ }
+ : {}),
+ }
+ : region,
+ ),
+ }));
+ },
+ [pushState],
+ );
+
const handleAnnotationPositionChange = useCallback(
(id: string, position: { x: number; y: number }) => {
pushState((prev) => ({
@@ -1110,11 +1235,14 @@ export default function VideoEditor() {
useEffect(() => {
if (
selectedAnnotationId &&
- !annotationRegions.some((region) => region.id === selectedAnnotationId)
+ !annotationOnlyRegions.some((region) => region.id === selectedAnnotationId)
) {
setSelectedAnnotationId(null);
}
- }, [selectedAnnotationId, annotationRegions]);
+ if (selectedBlurId && !blurRegions.some((region) => region.id === selectedBlurId)) {
+ setSelectedBlurId(null);
+ }
+ }, [selectedAnnotationId, selectedBlurId, annotationOnlyRegions, blurRegions]);
useEffect(() => {
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
@@ -1689,11 +1817,18 @@ export default function VideoEditor() {
cropRegion={cropRegion}
trimRegions={trimRegions}
speedRegions={speedRegions}
- annotationRegions={annotationRegions}
+ annotationRegions={annotationOnlyRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
+ blurRegions={blurRegions}
+ selectedBlurId={selectedBlurId}
+ onSelectBlur={handleSelectBlur}
+ onBlurPositionChange={handleAnnotationPositionChange}
+ onBlurSizeChange={handleAnnotationSizeChange}
+ onBlurDataChange={handleBlurDataPreviewChange}
+ onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
/>
@@ -1747,18 +1882,25 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
- annotationRegions={annotationRegions}
+ annotationRegions={annotationOnlyRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
onAnnotationDelete={handleAnnotationDelete}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
+ blurRegions={blurRegions}
+ onBlurAdded={handleBlurAdded}
+ onBlurSpanChange={handleAnnotationSpanChange}
+ onBlurDelete={handleAnnotationDelete}
+ selectedBlurId={selectedBlurId}
+ onSelectBlur={handleSelectBlur}
aspectRatio={aspectRatio}
onAspectRatioChange={(ar) =>
pushState({
aspectRatio: ar,
webcamLayoutPreset:
- !isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
+ (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
+ (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
? "picture-in-picture"
: webcamLayoutPreset,
})
@@ -1811,7 +1953,7 @@ export default function VideoEditor() {
onWebcamLayoutPresetChange={(preset) =>
pushState({
webcamLayoutPreset: preset,
- webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
+ webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
})
}
webcamMaskShape={webcamMaskShape}
@@ -1845,12 +1987,17 @@ export default function VideoEditor() {
)}
onExport={handleOpenExportDialog}
selectedAnnotationId={selectedAnnotationId}
- annotationRegions={annotationRegions}
+ annotationRegions={annotationOnlyRegions}
onAnnotationContentChange={handleAnnotationContentChange}
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationDelete={handleAnnotationDelete}
+ selectedBlurId={selectedBlurId}
+ blurRegions={blurRegions}
+ onBlurDataChange={handleBlurDataPanelChange}
+ onBlurDataCommit={commitState}
+ onBlurDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
selectedSpeedId
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 08c1c25..ea477c8 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -35,6 +35,7 @@ import {
import { AnnotationOverlay } from "./AnnotationOverlay";
import {
type AnnotationRegion,
+ type BlurData,
type SpeedRegion,
type TrimRegion,
ZOOM_DEPTH_SCALES,
@@ -101,6 +102,13 @@ interface VideoPlaybackProps {
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
+ blurRegions?: AnnotationRegion[];
+ selectedBlurId?: string | null;
+ onSelectBlur?: (id: string | null) => void;
+ onBlurPositionChange?: (id: string, position: { x: number; y: number }) => void;
+ onBlurSizeChange?: (id: string, size: { width: number; height: number }) => void;
+ onBlurDataChange?: (id: string, blurData: BlurData) => void;
+ onBlurDataCommit?: () => void;
cursorTelemetry?: import("./types").CursorTelemetryPoint[];
}
@@ -152,6 +160,13 @@ const VideoPlayback = forwardRef
(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
+ blurRegions = [],
+ selectedBlurId,
+ onSelectBlur,
+ onBlurPositionChange,
+ onBlurSizeChange,
+ onBlurDataChange,
+ onBlurDataCommit,
cursorTelemetry = [],
},
ref,
@@ -166,6 +181,8 @@ const VideoPlayback = forwardRef(
const timeUpdateAnimationRef = useRef(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
+ const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
+ const [overlayElement, setOverlayElement] = useState(null);
const overlayRef = useRef(null);
const focusIndicatorRef = useRef(null);
const [webcamLayout, setWebcamLayout] = useState(null);
@@ -330,6 +347,11 @@ const VideoPlayback = forwardRef(
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
+ const setOverlayRefs = useCallback((node: HTMLDivElement | null) => {
+ overlayRef.current = node;
+ setOverlayElement(node);
+ }, []);
+
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
@@ -623,7 +645,8 @@ const VideoPlayback = forwardRef(
}, [selectedZoom, pixiReady, videoReady, updateOverlayForRegion]);
useEffect(() => {
- const overlayEl = overlayRef.current;
+ if (!pixiReady || !videoReady) return;
+ const overlayEl = overlayElement;
if (!overlayEl) return;
if (!selectedZoom) {
overlayEl.style.cursor = "default";
@@ -632,7 +655,34 @@ const VideoPlayback = forwardRef(
}
overlayEl.style.cursor = isPlaying ? "not-allowed" : "grab";
overlayEl.style.pointerEvents = isPlaying ? "none" : "auto";
- }, [selectedZoom, isPlaying]);
+ }, [selectedZoom, isPlaying, pixiReady, videoReady, overlayElement]);
+
+ useEffect(() => {
+ const overlayEl = overlayElement;
+ if (!overlayEl) return;
+
+ const updateOverlaySize = () => {
+ const width = overlayEl.clientWidth || 800;
+ const height = overlayEl.clientHeight || 600;
+ setOverlaySize((prev) => {
+ if (prev.width === width && prev.height === height) return prev;
+ return { width, height };
+ });
+ };
+
+ updateOverlaySize();
+
+ if (typeof ResizeObserver !== "undefined") {
+ const observer = new ResizeObserver(() => {
+ updateOverlaySize();
+ });
+ observer.observe(overlayEl);
+ return () => observer.disconnect();
+ }
+
+ window.addEventListener("resize", updateOverlaySize);
+ return () => window.removeEventListener("resize", updateOverlaySize);
+ }, [overlayElement]);
useEffect(() => {
const container = containerRef.current;
@@ -865,22 +915,12 @@ const VideoPlayback = forwardRef(
};
const ticker = () => {
- const bm = baseMaskRef.current;
- const ss = stageSizeRef.current;
- const viewportRatio =
- bm.width > 0 && bm.height > 0
- ? {
- widthRatio: ss.width / bm.width,
- heightRatio: ss.height / bm.height,
- }
- : undefined;
const { region, strength, blendedScale, transition } = findDominantRegion(
zoomRegionsRef.current,
currentTimeRef.current,
{
connectZooms: true,
cursorTelemetry: cursorTelemetryRef.current,
- viewportRatio,
},
);
@@ -1287,7 +1327,7 @@ const VideoPlayback = forwardRef(
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
(
style={{ display: "none", pointerEvents: "none" }}
/>
{(() => {
- const filtered = (annotationRegions || []).filter((annotation) => {
+ const filteredAnnotations = (annotationRegions || []).filter((annotation) => {
if (typeof annotation.startMs !== "number" || typeof annotation.endMs !== "number")
return false;
@@ -1311,37 +1351,93 @@ const VideoPlayback = forwardRef
(
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
});
- // Sort by z-index (lowest to highest) so higher z-index renders on top
- const sorted = [...filtered].sort((a, b) => a.zIndex - b.zIndex);
+ const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
+ if (typeof blurRegion.startMs !== "number" || typeof blurRegion.endMs !== "number")
+ return false;
+
+ if (blurRegion.id === selectedBlurId) return true;
+
+ const timeMs = Math.round(currentTime * 1000);
+ return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs;
+ });
+
+ const sorted = [
+ ...filteredAnnotations.map((annotation) => ({
+ kind: "annotation" as const,
+ region: annotation,
+ })),
+ ...filteredBlurRegions.map((blurRegion) => ({
+ kind: "blur" as const,
+ region: blurRegion,
+ })),
+ ].sort((a, b) => a.region.zIndex - b.region.zIndex);
// Handle click-through cycling: when clicking same annotation, cycle to next
const handleAnnotationClick = (clickedId: string) => {
if (!onSelectAnnotation) return;
// If clicking on already selected annotation and there are multiple overlapping
- if (clickedId === selectedAnnotationId && sorted.length > 1) {
+ if (clickedId === selectedAnnotationId && filteredAnnotations.length > 1) {
// Find current index and cycle to next
- const currentIndex = sorted.findIndex((a) => a.id === clickedId);
- const nextIndex = (currentIndex + 1) % sorted.length;
- onSelectAnnotation(sorted[nextIndex].id);
+ const currentIndex = filteredAnnotations.findIndex((a) => a.id === clickedId);
+ const nextIndex = (currentIndex + 1) % filteredAnnotations.length;
+ onSelectAnnotation(filteredAnnotations[nextIndex].id);
} else {
// First click or clicking different annotation
onSelectAnnotation(clickedId);
}
};
- return sorted.map((annotation) => (
+ const handleBlurClick = (clickedId: string) => {
+ if (!onSelectBlur) return;
+
+ if (clickedId === selectedBlurId && filteredBlurRegions.length > 1) {
+ const currentIndex = filteredBlurRegions.findIndex((a) => a.id === clickedId);
+ const nextIndex = (currentIndex + 1) % filteredBlurRegions.length;
+ onSelectBlur(filteredBlurRegions[nextIndex].id);
+ } else {
+ onSelectBlur(clickedId);
+ }
+ };
+
+ return sorted.map((item) => (
onAnnotationPositionChange?.(id, position)}
- onSizeChange={(id, size) => onAnnotationSizeChange?.(id, size)}
- onClick={handleAnnotationClick}
- zIndex={annotation.zIndex}
- isSelectedBoost={annotation.id === selectedAnnotationId}
+ key={
+ item.kind === "blur"
+ ? `${item.region.id}-${overlaySize.width}-${overlaySize.height}-${item.region.blurData?.shape ?? "rectangle"}-${(item.region.blurData?.freehandPoints ?? []).map((p) => `${Math.round(p.x)}_${Math.round(p.y)}`).join("-")}`
+ : `${item.region.id}-${overlaySize.width}-${overlaySize.height}`
+ }
+ annotation={item.region}
+ isSelected={
+ item.kind === "blur"
+ ? item.region.id === selectedBlurId
+ : item.region.id === selectedAnnotationId
+ }
+ containerWidth={overlaySize.width}
+ containerHeight={overlaySize.height}
+ onPositionChange={(id, position) =>
+ item.kind === "blur"
+ ? onBlurPositionChange?.(id, position)
+ : onAnnotationPositionChange?.(id, position)
+ }
+ onSizeChange={(id, size) =>
+ item.kind === "blur"
+ ? onBlurSizeChange?.(id, size)
+ : onAnnotationSizeChange?.(id, size)
+ }
+ onBlurDataChange={
+ item.kind === "blur"
+ ? (id, blurData) => onBlurDataChange?.(id, blurData)
+ : undefined
+ }
+ onBlurDataCommit={item.kind === "blur" ? onBlurDataCommit : undefined}
+ onClick={item.kind === "blur" ? handleBlurClick : handleAnnotationClick}
+ zIndex={item.region.zIndex}
+ isSelectedBoost={
+ item.kind === "blur"
+ ? item.region.id === selectedBlurId
+ : item.region.id === selectedAnnotationId
+ }
/>
));
})()}
diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts
index fdf5f66..9a99ef7 100644
--- a/src/components/video-editor/projectPersistence.test.ts
+++ b/src/components/video-editor/projectPersistence.test.ts
@@ -44,6 +44,7 @@ describe("projectPersistence media compatibility", () => {
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
+ webcamPosition: null,
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -66,6 +67,30 @@ describe("projectPersistence media compatibility", () => {
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
).toBe("rectangle");
});
+
+ it("accepts the dual frame webcam layout preset", () => {
+ expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
+ "dual-frame",
+ );
+ });
+
+ it("falls back from dual frame to picture in picture for portrait aspect ratios", () => {
+ expect(
+ normalizeProjectEditor({
+ aspectRatio: "9:16",
+ webcamLayoutPreset: "dual-frame",
+ }).webcamLayoutPreset,
+ ).toBe("picture-in-picture");
+ });
+
+ it("clears webcamPosition when the normalized preset is not picture in picture", () => {
+ expect(
+ normalizeProjectEditor({
+ webcamLayoutPreset: "dual-frame",
+ webcamPosition: { cx: 0.2, cy: 0.8 },
+ }).webcamPosition,
+ ).toBeNull();
+ });
});
it("creates stable snapshots for identical project state", () => {
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 45513d4..a8362c8 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -1,7 +1,7 @@
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
-import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
+import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import {
type AnnotationRegion,
type CropRegion,
@@ -9,6 +9,9 @@ import {
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_BLUR_DATA,
+ DEFAULT_BLUR_FREEHAND_POINTS,
+ DEFAULT_BLUR_INTENSITY,
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
@@ -17,7 +20,9 @@ import {
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
+ MAX_BLUR_INTENSITY,
MAX_PLAYBACK_SPEED,
+ MIN_BLUR_INTENSITY,
MIN_PLAYBACK_SPEED,
type SpeedRegion,
type TrimRegion,
@@ -29,6 +34,7 @@ import {
} from "./types";
const WALLPAPER_COUNT = 18;
+const VALID_BLUR_SHAPES = new Set(["rectangle", "oval", "freehand"] as const);
export const WALLPAPER_PATHS = Array.from(
{ length: WALLPAPER_COUNT },
@@ -72,6 +78,26 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
+function computeNormalizedWebcamLayoutPreset(
+ webcamLayoutPreset: Partial["webcamLayoutPreset"],
+ normalizedAspectRatio: AspectRatio,
+): WebcamLayoutPreset {
+ switch (webcamLayoutPreset) {
+ case "picture-in-picture":
+ return webcamLayoutPreset;
+ case "vertical-stack":
+ return isPortraitAspectRatio(normalizedAspectRatio)
+ ? webcamLayoutPreset
+ : DEFAULT_WEBCAM_LAYOUT_PRESET;
+ case "dual-frame":
+ return isPortraitAspectRatio(normalizedAspectRatio)
+ ? DEFAULT_WEBCAM_LAYOUT_PRESET
+ : webcamLayoutPreset;
+ default:
+ return DEFAULT_WEBCAM_LAYOUT_PRESET;
+ }
+}
+
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
@@ -179,6 +205,26 @@ export function resolveProjectMedia(
export function normalizeProjectEditor(editor: Partial): ProjectEditorState {
const validAspectRatios = new Set(ASPECT_RATIOS);
+ const normalizedAspectRatio: AspectRatio = validAspectRatios.has(
+ editor.aspectRatio as AspectRatio,
+ )
+ ? (editor.aspectRatio as AspectRatio)
+ : "16:9";
+ const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
+ editor.webcamLayoutPreset,
+ normalizedAspectRatio,
+ );
+ const normalizedWebcamPosition: WebcamPosition | null =
+ normalizedWebcamLayoutPreset === "picture-in-picture" &&
+ editor.webcamPosition &&
+ typeof editor.webcamPosition === "object" &&
+ isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
+ isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
+ ? {
+ cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
+ cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
+ }
+ : DEFAULT_WEBCAM_POSITION;
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
@@ -254,12 +300,20 @@ export function normalizeProjectEditor(editor: Partial): Pro
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
const endMs = Math.max(startMs + 1, rawEnd);
+ const blurShape =
+ typeof region.blurData?.shape === "string" &&
+ VALID_BLUR_SHAPES.has(region.blurData.shape)
+ ? region.blurData.shape
+ : DEFAULT_BLUR_DATA.shape;
return {
id: region.id,
startMs,
endMs,
- type: region.type === "image" || region.type === "figure" ? region.type : "text",
+ type:
+ region.type === "image" || region.type === "figure" || region.type === "blur"
+ ? region.type
+ : "text",
content: typeof region.content === "string" ? region.content : "",
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
@@ -306,6 +360,37 @@ export function normalizeProjectEditor(editor: Partial): Pro
...region.figureData,
}
: undefined,
+ blurData:
+ region.blurData && typeof region.blurData === "object"
+ ? {
+ ...DEFAULT_BLUR_DATA,
+ ...region.blurData,
+ shape: blurShape,
+ intensity: isFiniteNumber(region.blurData.intensity)
+ ? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
+ : DEFAULT_BLUR_INTENSITY,
+ freehandPoints: Array.isArray(region.blurData.freehandPoints)
+ ? region.blurData.freehandPoints
+ .filter(
+ (
+ point,
+ ): point is {
+ x: number;
+ y: number;
+ } =>
+ Boolean(
+ point &&
+ isFiniteNumber((point as { x?: unknown }).x) &&
+ isFiniteNumber((point as { y?: unknown }).y),
+ ),
+ )
+ .map((point) => ({
+ x: clamp(point.x, 0, 100),
+ y: clamp(point.y, 0, 100),
+ }))
+ : DEFAULT_BLUR_FREEHAND_POINTS,
+ }
+ : undefined,
};
})
: [];
@@ -351,13 +436,8 @@ export function normalizeProjectEditor(editor: Partial): Pro
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
- aspectRatio:
- editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
- webcamLayoutPreset:
- editor.webcamLayoutPreset === "vertical-stack" ||
- editor.webcamLayoutPreset === "picture-in-picture"
- ? editor.webcamLayoutPreset
- : DEFAULT_WEBCAM_LAYOUT_PRESET,
+ aspectRatio: normalizedAspectRatio,
+ webcamLayoutPreset: normalizedWebcamLayoutPreset,
webcamMaskShape:
editor.webcamMaskShape === "rectangle" ||
editor.webcamMaskShape === "circle" ||
@@ -369,16 +449,7 @@ export function normalizeProjectEditor(editor: Partial): Pro
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
: DEFAULT_WEBCAM_SIZE_PRESET,
- webcamPosition:
- editor.webcamPosition &&
- typeof editor.webcamPosition === "object" &&
- isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
- isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
- ? {
- cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
- cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
- }
- : DEFAULT_WEBCAM_POSITION,
+ webcamPosition: normalizedWebcamPosition,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index 27a4815..d89de94 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -21,8 +21,8 @@ interface ItemProps {
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
speedValue?: number;
- variant?: "zoom" | "trim" | "annotation" | "speed";
onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void;
+ variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
// Map zoom depth to multiplier labels
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 72f140b..61a8190 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -44,6 +44,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
+const BLUR_ROW_ID = "row-blur";
const SPEED_ROW_ID = "row-speed";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
@@ -74,6 +75,12 @@ interface TimelineEditorProps {
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
+ blurRegions?: AnnotationRegion[];
+ onBlurAdded?: (span: Span) => void;
+ onBlurSpanChange?: (id: string, span: Span) => void;
+ onBlurDelete?: (id: string) => void;
+ selectedBlurId?: string | null;
+ onSelectBlur?: (id: string | null) => void;
speedRegions?: SpeedRegion[];
onSpeedAdded?: (span: Span) => void;
onSpeedSpanChange?: (id: string, span: Span) => void;
@@ -99,7 +106,7 @@ interface TimelineRenderItem {
speedValue?: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
- variant: "zoom" | "trim" | "annotation" | "speed";
+ variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
const SCALE_CANDIDATES = [
@@ -528,10 +535,12 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
+ onSelectBlur,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
+ selectedBlurId,
selectedSpeedId,
onZoomDurationChange,
keyframes = [],
@@ -544,10 +553,12 @@ function Timeline({
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
+ onSelectBlur?: (id: string | null) => void;
onSelectSpeed?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
+ selectedBlurId?: string | null;
selectedSpeedId?: string | null;
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
keyframes?: { id: string; time: number }[];
@@ -573,6 +584,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
+ onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
@@ -591,6 +603,7 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
+ onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
@@ -642,6 +655,7 @@ function Timeline({
const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
+ const blurItems = items.filter((item) => item.rowId === BLUR_ROW_ID);
const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
return (
@@ -719,6 +733,22 @@ function Timeline({
))}
+
+ {blurItems.map((item) => (
+ - onSelectBlur?.(item.id)}
+ variant={item.variant}
+ >
+ {item.label}
+
+ ))}
+
+
{speedItems.map((item) => (
- {
+ if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
+ onBlurDelete(selectedBlurId);
+ onSelectBlur(null);
+ }, [selectedBlurId, onBlurDelete, onSelectBlur]);
+
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
@@ -917,9 +959,10 @@ export default function TimelineEditor({
const isZoomItem = zoomRegions.some((r) => r.id === excludeId);
const isTrimItem = trimRegions.some((r) => r.id === excludeId);
const isAnnotationItem = annotationRegions.some((r) => r.id === excludeId);
+ const isBlurItem = blurRegions.some((r) => r.id === excludeId);
const isSpeedItem = speedRegions.some((r) => r.id === excludeId);
- if (isAnnotationItem) {
+ if (isAnnotationItem || isBlurItem) {
return false;
}
@@ -946,7 +989,7 @@ export default function TimelineEditor({
return false;
},
- [zoomRegions, trimRegions, annotationRegions, speedRegions],
+ [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions],
);
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
@@ -1174,6 +1217,21 @@ export default function TimelineEditor({
onAnnotationAdded({ start: startPos, end: endPos });
}, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]);
+ const handleAddBlur = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onBlurAdded) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ const endPos = Math.min(startPos + defaultDuration, totalMs);
+ onBlurAdded({ start: startPos, end: endPos });
+ }, [videoDuration, totalMs, currentTimeMs, onBlurAdded, defaultRegionDurationMs]);
+
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -1192,6 +1250,9 @@ export default function TimelineEditor({
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
+ if (matchesShortcut(e, keyShortcuts.addBlur, isMac)) {
+ handleAddBlur();
+ }
if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
handleAddSpeed();
}
@@ -1232,6 +1293,8 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
+ } else if (selectedBlurId) {
+ deleteSelectedBlur();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
@@ -1244,18 +1307,22 @@ export default function TimelineEditor({
handleAddZoom,
handleAddTrim,
handleAddAnnotation,
+ handleAddBlur,
handleAddSpeed,
deleteSelectedKeyframe,
deleteSelectedZoom,
deleteSelectedTrim,
deleteSelectedAnnotation,
+ deleteSelectedBlur,
deleteSelectedSpeed,
selectedKeyframeId,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
+ selectedBlurId,
selectedSpeedId,
annotationRegions,
+ blurRegions,
currentTime,
onSelectAnnotation,
keyShortcuts,
@@ -1315,6 +1382,14 @@ export default function TimelineEditor({
};
});
+ const blurs: TimelineRenderItem[] = blurRegions.map((region, index) => ({
+ id: region.id,
+ rowId: BLUR_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: t("labels.blurItem", { index: String(index + 1) }),
+ variant: "blur",
+ }));
+
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
id: region.id,
rowId: SPEED_ROW_ID,
@@ -1324,8 +1399,8 @@ export default function TimelineEditor({
variant: "speed",
}));
- return [...zooms, ...trims, ...annotations, ...speeds];
- }, [zoomRegions, trimRegions, annotationRegions, speedRegions, t]);
+ return [...zooms, ...trims, ...annotations, ...blurs, ...speeds];
+ }, [zoomRegions, trimRegions, annotationRegions, blurRegions, speedRegions, t]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
@@ -1346,6 +1421,8 @@ export default function TimelineEditor({
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some((r) => r.id === id)) {
onAnnotationSpanChange?.(id, span);
+ } else if (blurRegions.some((r) => r.id === id)) {
+ onBlurSpanChange?.(id, span);
}
},
[
@@ -1353,10 +1430,12 @@ export default function TimelineEditor({
trimRegions,
speedRegions,
annotationRegions,
+ blurRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAnnotationSpanChange,
+ onBlurSpanChange,
],
);
@@ -1414,6 +1493,25 @@ export default function TimelineEditor({
>
+