Merge pull request #394 from LorenzoLancia/feature/blur-selection

feat: add blur selection (rectangle, oval)
This commit is contained in:
Sid
2026-04-10 07:10:29 -07:00
committed by GitHub
29 changed files with 1460 additions and 568 deletions
+398 -498
View File
File diff suppressed because it is too large Load Diff
+248 -12
View File
@@ -1,8 +1,28 @@
import { useRef } from "react";
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
import { Rnd } from "react-rnd";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
import type { AnnotationRegion } from "./types";
import {
type AnnotationRegion,
type BlurData,
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_INTENSITY,
} from "./types";
const FREEHAND_POINT_THRESHOLD = 1;
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
if (points.length < 3) return undefined;
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
return `polygon(${polygon})`;
}
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
if (closed ? points.length < 3 : points.length < 2) return null;
const [firstPoint, ...rest] = points;
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
return closed ? `${path} Z` : path;
}
interface AnnotationOverlayProps {
annotation: AnnotationRegion;
@@ -11,6 +31,8 @@ interface AnnotationOverlayProps {
containerHeight: number;
onPositionChange: (id: string, position: { x: number; y: number }) => void;
onSizeChange: (id: string, size: { width: number; height: number }) => void;
onBlurDataChange?: (id: string, blurData: BlurData) => void;
onBlurDataCommit?: () => void;
onClick: (id: string) => void;
zIndex: number;
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
@@ -23,6 +45,8 @@ export function AnnotationOverlay({
containerHeight,
onPositionChange,
onSizeChange,
onBlurDataChange,
onBlurDataCommit,
onClick,
zIndex,
isSelectedBoost,
@@ -31,8 +55,16 @@ export function AnnotationOverlay({
const y = (annotation.position.y / 100) * containerHeight;
const width = (annotation.size.width / 100) * containerWidth;
const height = (annotation.size.height / 100) * containerHeight;
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
const isDraggingRef = useRef(false);
const isDrawingFreehandRef = useRef(false);
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
[],
);
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
const renderArrow = () => {
const direction = annotation.figureData?.arrowDirection || "right";
@@ -43,6 +75,95 @@ export function AnnotationOverlay({
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
};
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
return {
x: Math.max(0, Math.min(100, x)),
y: Math.max(0, Math.min(100, y)),
};
};
const appendFreehandPoint = (point: { x: number; y: number }) => {
const points = freehandPointsRef.current;
const lastPoint = points[points.length - 1];
if (!lastPoint) {
points.push(point);
return;
}
const dx = point.x - lastPoint.x;
const dy = point.y - lastPoint.y;
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
points.push(point);
}
};
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
!isSelected ||
annotation.type !== "blur" ||
annotation.blurData?.shape !== "freehand" ||
!onBlurDataChange
) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
isDrawingFreehandRef.current = true;
setIsFreehandDrawing(true);
const point = normalizePoint(event);
freehandPointsRef.current = [point];
setDraftFreehandPoints([point]);
setLivePointerPoint(point);
};
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current) return;
event.preventDefault();
event.stopPropagation();
const point = normalizePoint(event);
setLivePointerPoint(point);
appendFreehandPoint(point);
setDraftFreehandPoints([...freehandPointsRef.current]);
};
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
isDrawingFreehandRef.current = false;
setIsFreehandDrawing(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// no-op if already released
}
const points = [...freehandPointsRef.current];
if (livePointerPoint) {
const last = points[points.length - 1];
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
points.push(livePointerPoint);
}
}
if (points.length >= 3) {
const closedPoints = [...points];
const first = closedPoints[0];
const last = closedPoints[closedPoints.length - 1];
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
closedPoints.push({ ...first });
}
onBlurDataChange(annotation.id, {
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
shape: "freehand",
freehandPoints: closedPoints,
});
setDraftFreehandPoints(closedPoints);
onBlurDataCommit?.();
}
setLivePointerPoint(null);
};
const renderContent = () => {
switch (annotation.type) {
case "text":
@@ -113,6 +234,114 @@ export function AnnotationOverlay({
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
);
case "blur": {
const shape = annotation.blurData?.shape ?? "rectangle";
const blurIntensity = Math.max(
1,
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
);
const activeFreehandPoints =
shape === "freehand"
? isFreehandDrawing
? draftFreehandPoints
: (annotation.blurData?.freehandPoints ?? [])
: [];
const drawingPoints =
isFreehandDrawing && livePointerPoint
? (() => {
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
if (!last) return [livePointerPoint];
const dx = livePointerPoint.x - last.x;
const dy = livePointerPoint.y - last.y;
return Math.hypot(dx, dy) > 0.01
? [...activeFreehandPoints, livePointerPoint]
: activeFreehandPoints;
})()
: activeFreehandPoints;
const clipPath =
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
const freehandPath =
shape === "freehand"
? buildBlurFreehandPath(
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
!isFreehandDrawing,
)
: null;
const currentPointerPoint = isFreehandDrawing
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
: null;
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
const shouldShowFreehandBlurFill =
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
const shapeMaskStyle: CSSProperties = {
borderRadius: shapeBorderRadius,
clipPath: isFreehandDrawing ? undefined : clipPath,
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
};
const isFreehandSelected = isSelectedFreehandBlur;
return (
<div className="w-full h-full relative">
<div
className="absolute inset-0 overflow-hidden"
style={{
...shapeMaskStyle,
isolation: "isolate",
}}
>
<div
className="absolute inset-0"
style={{
...shapeMaskStyle,
backdropFilter: `blur(${blurIntensity}px)`,
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
backgroundColor: "rgba(255, 255, 255, 0.02)",
opacity: shouldShowFreehandBlurFill ? 1 : 0,
}}
/>
{isSelected && shape !== "freehand" && (
<div
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
style={{ borderRadius: shapeBorderRadius }}
/>
)}
</div>
{isSelected && shape === "freehand" && freehandPath && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 pointer-events-none"
>
<path
d={freehandPath}
fill="none"
stroke="#34B27B"
strokeWidth="0.55"
strokeLinecap="round"
strokeLinejoin="round"
/>
{currentPointerPoint && (
<circle
cx={currentPointerPoint.x}
cy={currentPointerPoint.y}
r="0.6"
fill="#34B27B"
/>
)}
</svg>
)}
{isFreehandSelected && (
<div
className="absolute inset-0 cursor-crosshair"
onPointerDown={handleFreehandPointerDown}
onPointerMove={handleFreehandPointerMove}
onPointerUp={finishFreehandPointer}
onPointerCancel={finishFreehandPointer}
/>
)}
</div>
);
}
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({
>
<div
className={cn(
"w-full h-full rounded-lg",
"w-full h-full",
annotation.type !== "blur" && "rounded-lg",
annotation.type === "text" && "bg-transparent",
annotation.type === "image" && "bg-transparent",
annotation.type === "figure" && "bg-transparent",
isSelected && "shadow-lg",
annotation.type === "blur" && "bg-transparent",
isSelected && annotation.type !== "blur" && "shadow-lg",
)}
>
{renderContent()}
@@ -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;
@@ -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 (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl p-4 flex flex-col shadow-xl h-full overflow-y-auto custom-scrollbar">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-slate-200">{t("annotation.blurShape")}</span>
<span className="text-[10px] uppercase tracking-wider font-medium text-[#34B27B] bg-[#34B27B]/10 px-2 py-1 rounded-full">
{t("annotation.active")}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{blurShapeOptions.map((shape) => {
const activeShape = blurRegion.blurData?.shape || DEFAULT_BLUR_DATA.shape;
const isActive = activeShape === shape.value;
return (
<button
key={shape.value}
onClick={() => {
const nextBlurData: BlurData = {
...DEFAULT_BLUR_DATA,
...blurRegion.blurData,
shape: shape.value,
};
onBlurDataChange(nextBlurData);
requestAnimationFrame(() => {
onBlurDataCommit?.();
});
}}
className={cn(
"h-16 rounded-lg border flex flex-col items-center justify-center transition-all p-2 gap-1",
isActive
? "bg-[#34B27B] border-[#34B27B]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
)}
>
{shape.value === "rectangle" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-sm",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
{shape.value === "oval" && (
<div
className={cn(
"w-8 h-5 border-2 rounded-full",
isActive ? "border-white" : "border-slate-400",
)}
/>
)}
<span className="text-[10px] leading-none">
{t(`annotation.${shape.labelKey}`)}
</span>
</button>
);
})}
</div>
<div className="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-300">
{t("annotation.blurIntensity")}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
</span>
</div>
<Slider
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
onValueChange={(values) => {
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"
/>
</div>
<Button
onClick={onDelete}
variant="destructive"
size="sm"
className="w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all mt-4"
>
<Trash2 className="w-4 h-4" />
{t("annotation.deleteAnnotation")}
</Button>
<div className="mt-6 p-3 bg-white/5 rounded-lg border border-white/5">
<div className="flex items-center gap-2 mb-2 text-slate-300">
<Info className="w-3.5 h-3.5" />
<span className="text-xs font-medium">{t("annotation.shortcutsAndTips")}</span>
</div>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3 leading-relaxed">
<li>{t("annotation.tipMovePlayhead")}</li>
</ul>
</div>
</div>
</div>
);
}
@@ -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<AnnotationRegion["style"]>) => 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;
@@ -285,6 +292,11 @@ export function SettingsPanel({
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
selectedBlurId,
blurRegions = [],
onBlurDataChange,
onBlurDataCommit,
onBlurDelete,
selectedSpeedId,
selectedSpeedValue,
onSpeedChange,
@@ -520,6 +532,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 (
@@ -545,6 +560,17 @@ export function SettingsPanel({
);
}
if (selectedBlur && onBlurDataChange && onBlurDelete) {
return (
<BlurSettingsPanel
blurRegion={selectedBlur}
onBlurDataChange={(blurData) => onBlurDataChange(selectedBlur.id, blurData)}
onBlurDataCommit={onBlurDataCommit}
onDelete={() => onBlurDelete(selectedBlur.id)}
/>
);
}
return (
<div className="flex-[2] min-w-0 bg-[#09090b] border border-white/5 rounded-2xl flex flex-col shadow-xl h-full overflow-hidden">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 pb-0">
+154 -8
View File
@@ -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,
@@ -122,6 +124,7 @@ export default function VideoEditor() {
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
const [selectedBlurId, setSelectedBlurId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
@@ -157,6 +160,15 @@ export default function VideoEditor() {
const nextAnnotationZIndexRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(null);
const annotationOnlyRegions = useMemo(
() => annotationRegions.filter((region) => region.type !== "blur"),
[annotationRegions],
);
const blurRegions = useMemo(
() => annotationRegions.filter((region) => region.type === "blur"),
[annotationRegions],
);
const currentProjectMedia = useMemo<ProjectMedia | null>(() => {
const screenVideoPath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
if (!screenVideoPath) {
@@ -229,6 +241,7 @@ export default function VideoEditor() {
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
nextZoomIdRef.current = deriveNextId(
"zoom",
@@ -626,7 +639,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) => {
@@ -634,6 +651,7 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
@@ -642,6 +660,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);
}
}, []);
@@ -659,6 +688,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -677,6 +707,7 @@ export default function VideoEditor() {
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -693,6 +724,7 @@ export default function VideoEditor() {
setSelectedTrimId(id);
setSelectedZoomId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -803,6 +835,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
}
}, []);
@@ -822,6 +855,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -888,6 +922,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],
);
@@ -917,8 +980,11 @@ export default function VideoEditor() {
if (selectedAnnotationId === id) {
setSelectedAnnotationId(null);
}
if (selectedBlurId === id) {
setSelectedBlurId(null);
}
},
[selectedAnnotationId, pushState],
[selectedAnnotationId, selectedBlurId, pushState],
);
const handleAnnotationContentChange = useCallback(
@@ -953,12 +1019,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(
@@ -983,6 +1063,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) => ({
@@ -1096,11 +1221,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)) {
@@ -1675,11 +1803,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}
/>
</div>
@@ -1732,12 +1867,18 @@ 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({
@@ -1830,12 +1971,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
+127 -21
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
blurRegions = [],
selectedBlurId,
onSelectBlur,
onBlurPositionChange,
onBlurSizeChange,
onBlurDataChange,
onBlurDataCommit,
cursorTelemetry = [],
},
ref,
@@ -166,6 +181,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const timeUpdateAnimationRef = useRef<number | null>(null);
const [pixiReady, setPixiReady] = useState(false);
const [videoReady, setVideoReady] = useState(false);
const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 });
const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const [webcamLayout, setWebcamLayout] = useState<StyledRenderRect | null>(null);
@@ -330,6 +347,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
}, [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<VideoPlaybackRef, VideoPlaybackProps>(
}
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;
@@ -1287,7 +1337,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
<div
ref={overlayRef}
ref={setOverlayRefs}
className="absolute inset-0 select-none"
style={{ pointerEvents: "none", zIndex: 30 }}
onPointerDown={handleOverlayPointerDown}
@@ -1301,7 +1351,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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 +1361,93 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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) => (
<AnnotationOverlay
key={annotation.id}
annotation={annotation}
isSelected={annotation.id === selectedAnnotationId}
containerWidth={overlayRef.current?.clientWidth || 800}
containerHeight={overlayRef.current?.clientHeight || 600}
onPositionChange={(id, position) => 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
}
/>
));
})()}
@@ -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 },
@@ -254,12 +260,20 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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 +320,37 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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,
};
})
: [];
@@ -14,7 +14,7 @@ interface ItemProps {
onSelect?: () => void;
zoomDepth?: number;
speedValue?: number;
variant?: "zoom" | "trim" | "annotation" | "speed";
variant?: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
// Map zoom depth to multiplier labels
@@ -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;
@@ -73,6 +74,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;
@@ -96,7 +103,7 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
variant: "zoom" | "trim" | "annotation" | "speed";
variant: "zoom" | "trim" | "annotation" | "speed" | "blur";
}
const SCALE_CANDIDATES = [
@@ -525,10 +532,12 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
selectedBlurId,
selectedSpeedId,
keyframes = [],
}: {
@@ -540,10 +549,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;
keyframes?: { id: string; time: number }[];
}) {
@@ -568,6 +579,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
@@ -586,6 +598,7 @@ function Timeline({
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
@@ -637,6 +650,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 (
@@ -711,6 +725,22 @@ function Timeline({
))}
</Row>
<Row id={BLUR_ROW_ID} isEmpty={blurItems.length === 0} hint={t("hints.pressBlur")}>
{blurItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedBlurId}
onSelect={() => onSelectBlur?.(item.id)}
variant={item.variant}
>
{item.label}
</Item>
))}
</Row>
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint={t("hints.pressSpeed")}>
{speedItems.map((item) => (
<Item
@@ -755,6 +785,12 @@ export default function TimelineEditor({
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
blurRegions = [],
onBlurAdded,
onBlurSpanChange,
onBlurDelete,
selectedBlurId,
onSelectBlur,
speedRegions = [],
onSpeedAdded,
onSpeedSpanChange,
@@ -839,6 +875,12 @@ export default function TimelineEditor({
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
const deleteSelectedBlur = useCallback(() => {
if (!selectedBlurId || !onBlurDelete || !onSelectBlur) return;
onBlurDelete(selectedBlurId);
onSelectBlur(null);
}, [selectedBlurId, onBlurDelete, onSelectBlur]);
const deleteSelectedSpeed = useCallback(() => {
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
onSpeedDelete(selectedSpeedId);
@@ -908,9 +950,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;
}
@@ -937,7 +980,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
@@ -1165,6 +1208,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) {
@@ -1183,6 +1241,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();
}
@@ -1223,6 +1284,8 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
} else if (selectedBlurId) {
deleteSelectedBlur();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
}
@@ -1235,18 +1298,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,
@@ -1304,6 +1371,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,
@@ -1313,8 +1388,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(() => {
@@ -1335,6 +1410,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);
}
},
[
@@ -1342,10 +1419,12 @@ export default function TimelineEditor({
trimRegions,
speedRegions,
annotationRegions,
blurRegions,
onZoomSpanChange,
onTrimSpanChange,
onSpeedSpanChange,
onAnnotationSpanChange,
onBlurSpanChange,
],
);
@@ -1403,6 +1482,25 @@ export default function TimelineEditor({
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
onClick={handleAddBlur}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#7dd3fc] hover:bg-[#7dd3fc]/10 transition-all"
title={t("buttons.addBlur")}
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="8" cy="12" r="3" />
<circle cx="16" cy="12" r="3" />
<path d="M6 6h12M6 18h12" />
</svg>
</Button>
<Button
onClick={handleAddSpeed}
variant="ghost"
@@ -1489,10 +1587,12 @@ export default function TimelineEditor({
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
onSelectBlur={onSelectBlur}
onSelectSpeed={onSelectSpeed}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedBlurId={selectedBlurId}
selectedSpeedId={selectedSpeedId}
keyframes={keyframes}
/>
+33 -1
View File
@@ -47,7 +47,7 @@ export interface TrimRegion {
endMs: number;
}
export type AnnotationType = "text" | "image" | "figure";
export type AnnotationType = "text" | "image" | "figure" | "blur";
export type ArrowDirection =
| "up"
@@ -65,6 +65,19 @@ export interface FigureData {
strokeWidth: number;
}
export type BlurShape = "rectangle" | "oval" | "freehand";
export const MIN_BLUR_INTENSITY = 2;
export const MAX_BLUR_INTENSITY = 40;
export const DEFAULT_BLUR_INTENSITY = 12;
export interface BlurData {
shape: BlurShape;
intensity: number;
// Points are normalized (0-100) within the annotation bounds.
freehandPoints?: Array<{ x: number; y: number }>;
}
export interface AnnotationPosition {
x: number;
y: number;
@@ -99,6 +112,7 @@ export interface AnnotationRegion {
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
blurData?: BlurData;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
@@ -128,6 +142,24 @@ export const DEFAULT_FIGURE_DATA: FigureData = {
strokeWidth: 4,
};
export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
{ x: 10, y: 30 },
{ x: 25, y: 10 },
{ x: 55, y: 8 },
{ x: 82, y: 20 },
{ x: 90, y: 45 },
{ x: 78, y: 72 },
{ x: 52, y: 90 },
{ x: 22, y: 84 },
{ x: 8, y: 58 },
];
export const DEFAULT_BLUR_DATA: BlurData = {
shape: "rectangle",
intensity: DEFAULT_BLUR_INTENSITY,
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
};
export interface CropRegion {
x: number;
y: number;
+1 -1
View File
@@ -1,5 +1,5 @@
export const DEFAULT_LOCALE = "en" as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", , "fr", "tr"] as const;
export const SUPPORTED_LOCALES = ["en", "zh-CN", "es", "fr", "tr"] as const;
export const I18N_NAMESPACES = [
"common",
"dialogs",
+6
View File
@@ -101,6 +101,7 @@
"typeText": "Text",
"typeImage": "Image",
"typeArrow": "Arrow",
"typeBlur": "Blur",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
@@ -117,6 +118,11 @@
"arrowDirection": "Arrow Direction",
"strokeWidth": "Stroke Width: {{width}}px",
"arrowColor": "Arrow Color",
"blurShape": "Blur Shape",
"blurIntensity": "Blur Intensity",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Oval",
"blurShapeFreehand": "Freehand",
"deleteAnnotation": "Delete Annotation",
"shortcutsAndTips": "Shortcuts & Tips",
"tipMovePlayhead": "Move playhead to overlapping annotation section and select an item.",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "Add Trim",
"addSpeed": "Add Speed",
"addAnnotation": "Add Annotation",
"addBlur": "Add Blur",
"addKeyframe": "Add Keyframe",
"deleteSelected": "Delete Selected",
"playPause": "Play / Pause"
+3
View File
@@ -4,12 +4,14 @@
"suggestZooms": "Suggest Zooms from Cursor",
"addTrim": "Add Trim (T)",
"addAnnotation": "Add Annotation (A)",
"addBlur": "Add Blur (B)",
"addSpeed": "Add Speed (S)"
},
"hints": {
"pressZoom": "Press Z to add zoom",
"pressTrim": "Press T to add trim",
"pressAnnotation": "Press A to add annotation",
"pressBlur": "Press B to add blur region",
"pressSpeed": "Press S to add speed"
},
"labels": {
@@ -19,6 +21,7 @@
"trimItem": "Trim {{index}}",
"speedItem": "Speed {{index}}",
"annotationItem": "Annotation",
"blurItem": "Blur {{index}}",
"imageItem": "Image",
"emptyText": "Empty text"
},
+6
View File
@@ -101,6 +101,7 @@
"typeText": "Texto",
"typeImage": "Imagen",
"typeArrow": "Flecha",
"typeBlur": "Desenfoque",
"textContent": "Contenido de texto",
"textPlaceholder": "Escribe tu texto...",
"fontStyle": "Estilo de fuente",
@@ -117,6 +118,11 @@
"arrowDirection": "Dirección de la flecha",
"strokeWidth": "Grosor del trazo: {{width}}px",
"arrowColor": "Color de la flecha",
"blurShape": "Forma del desenfoque",
"blurIntensity": "Intensidad del desenfoque",
"blurShapeRectangle": "Rectángulo",
"blurShapeOval": "Óvalo",
"blurShapeFreehand": "Mano alzada",
"deleteAnnotation": "Eliminar anotación",
"shortcutsAndTips": "Atajos y consejos",
"tipMovePlayhead": "Mueve el cabezal de reproducción a la sección de anotación superpuesta y selecciona un elemento.",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "Agregar recorte",
"addSpeed": "Agregar velocidad",
"addAnnotation": "Agregar anotación",
"addBlur": "Agregar desenfoque",
"addKeyframe": "Agregar fotograma clave",
"deleteSelected": "Eliminar seleccionado",
"playPause": "Reproducir / Pausar"
+6 -3
View File
@@ -4,13 +4,15 @@
"suggestZooms": "Sugerir zooms desde el cursor",
"addTrim": "Agregar recorte (T)",
"addAnnotation": "Agregar anotación (A)",
"addSpeed": "Agregar velocidad (S)"
"addSpeed": "Agregar velocidad (S)",
"addBlur": "Agregar desenfoque (B)"
},
"hints": {
"pressZoom": "Presiona Z para agregar zoom",
"pressTrim": "Presiona T para agregar recorte",
"pressAnnotation": "Presiona A para agregar anotación",
"pressSpeed": "Presiona S para agregar velocidad"
"pressSpeed": "Presiona S para agregar velocidad",
"pressBlur": "Presiona B para agregar una región de desenfoque"
},
"labels": {
"pan": "Desplazar",
@@ -20,7 +22,8 @@
"speedItem": "Velocidad {{index}}",
"annotationItem": "Anotación",
"imageItem": "Imagen",
"emptyText": "Texto vacío"
"emptyText": "Texto vacío",
"blurItem": "Desenfoque {{index}}"
},
"emptyState": {
"noVideo": "No hay video cargado",
+6
View File
@@ -98,6 +98,7 @@
"typeText": "Texte",
"typeImage": "Image",
"typeArrow": "Flèche",
"typeBlur": "Flou",
"textContent": "Contenu du texte",
"textPlaceholder": "Saisissez votre texte...",
"fontStyle": "Style de police",
@@ -114,6 +115,11 @@
"arrowDirection": "Direction de la flèche",
"strokeWidth": "Épaisseur du trait : {{width}}px",
"arrowColor": "Couleur de la flèche",
"blurShape": "Forme du flou",
"blurIntensity": "Intensité du flou",
"blurShapeRectangle": "Rectangle",
"blurShapeOval": "Ovale",
"blurShapeFreehand": "Main levée",
"deleteAnnotation": "Supprimer l'annotation",
"shortcutsAndTips": "Raccourcis & Astuces",
"tipMovePlayhead": "Déplacez la tête de lecture sur la section d'annotation et sélectionnez un élément.",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "Ajouter une coupe",
"addSpeed": "Ajouter une vitesse",
"addAnnotation": "Ajouter une annotation",
"addBlur": "Ajouter un flou",
"addKeyframe": "Ajouter une image-clé",
"deleteSelected": "Supprimer la sélection",
"playPause": "Lecture / Pause"
+11 -8
View File
@@ -4,13 +4,15 @@
"suggestZooms": "Suggérer des zooms depuis le curseur",
"addTrim": "Ajouter une coupe (T)",
"addAnnotation": "Ajouter une annotation (A)",
"addSpeed": "Ajouter une vitesse (S)"
"addSpeed": "Ajouter une vitesse (S)",
"addBlur": "Ajouter un flou (B)"
},
"hints": {
"pressZoom": "Appuyez sur Z pour ajouter un zoom",
"pressTrim": "Appuyez sur T pour ajouter une coupe",
"pressAnnotation": "Appuyez sur A pour ajouter une annotation",
"pressSpeed": "Appuyez sur S pour ajouter une vitesse"
"pressSpeed": "Appuyez sur S pour ajouter une vitesse",
"pressBlur": "Appuyez sur B pour ajouter une zone de flou"
},
"labels": {
"pan": "Panoramique",
@@ -20,7 +22,8 @@
"speedItem": "Vitesse {{index}}",
"annotationItem": "Annotation",
"imageItem": "Image",
"emptyText": "Texte vide"
"emptyText": "Texte vide",
"blurItem": "Flou {{index}}"
},
"emptyState": {
"noVideo": "Aucune vidéo chargée",
@@ -28,20 +31,20 @@
},
"errors": {
"cannotPlaceZoom": "Impossible de placer le zoom ici",
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"zoomExistsAtLocation": "Un zoom existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"zoomSuggestionUnavailable": "Gestionnaire de suggestions de zoom non disponible",
"noCursorTelemetry": "Aucune télémétrie de curseur disponible",
"noCursorTelemetryDescription": "Enregistrez d'abord un screencast pour générer des suggestions basées sur le curseur.",
"noCursorTelemetryDescription": "Enregistrez d\u0027abord un screencast pour générer des suggestions basées sur le curseur.",
"noUsableTelemetry": "Aucune télémétrie de curseur utilisable",
"noUsableTelemetryDescription": "L'enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
"noUsableTelemetryDescription": "L\u0027enregistrement ne contient pas suffisamment de données de mouvement du curseur.",
"noDwellMoments": "Aucun moment de pause du curseur trouvé",
"noDwellMomentsDescription": "Essayez un enregistrement avec des pauses plus lentes du curseur sur les actions importantes.",
"noAutoZoomSlots": "Aucun emplacement de zoom automatique disponible",
"noAutoZoomSlotsDescription": "Les points de pause détectés chevauchent des régions de zoom existantes.",
"cannotPlaceTrim": "Impossible de placer la coupe ici",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l'espace disponible est insuffisant.",
"trimExistsAtLocation": "Une coupe existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant.",
"cannotPlaceSpeed": "Impossible de placer la vitesse ici",
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l'espace disponible est insuffisant."
"speedExistsAtLocation": "Une région de vitesse existe déjà à cet emplacement ou l\u0027espace disponible est insuffisant."
},
"success": {
"addedZoomSuggestions": "{{count}} suggestion de zoom basée sur le curseur ajoutée",
+6
View File
@@ -98,6 +98,7 @@
"typeText": "Metin",
"typeImage": "Görüntü",
"typeArrow": "Ok",
"typeBlur": "Bulanık",
"textContent": "Metin İçeriği",
"textPlaceholder": "Metninizi girin...",
"fontStyle": "Yazı Tipi Stili",
@@ -114,6 +115,11 @@
"arrowDirection": "Ok Yönü",
"strokeWidth": "Çizgi Kalınlığı: {{width}}px",
"arrowColor": "Ok Rengi",
"blurShape": "Bulanık Şekli",
"blurIntensity": "Bulanıklık Yoğunluğu",
"blurShapeRectangle": "Dikdörtgen",
"blurShapeOval": "Oval",
"blurShapeFreehand": "Serbest",
"deleteAnnotation": "Açıklamayı Sil",
"shortcutsAndTips": "Kısayollar ve İpuçları",
"tipMovePlayhead": "Oynatma imlecini çakışan açıklama bölümüne taşıyın ve bir öğe seçin.",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "Kırpma Ekle",
"addSpeed": "Hız Ekle",
"addAnnotation": "Açıklama Ekle",
"addBlur": "Bulanik Ekle",
"addKeyframe": "Anahtar Kare Ekle",
"deleteSelected": "Seçileni Sil",
"playPause": "Oynat / Duraklat"
+6 -3
View File
@@ -4,13 +4,15 @@
"suggestZooms": "İmleçten Yakınlaştırma Öner",
"addTrim": "Kırpma Ekle (T)",
"addAnnotation": "Açıklama Ekle (A)",
"addSpeed": "Hız Ekle (S)"
"addSpeed": "Hız Ekle (S)",
"addBlur": "Bulanık ekle (B)"
},
"hints": {
"pressZoom": "Yakınlaştırma eklemek için Z tuşuna basın",
"pressTrim": "Kırpma eklemek için T tuşuna basın",
"pressAnnotation": "Açıklama eklemek için A tuşuna basın",
"pressSpeed": "Hız eklemek için S tuşuna basın"
"pressSpeed": "Hız eklemek için S tuşuna basın",
"pressBlur": "Bulanık bölge eklemek için B tuşuna basın"
},
"labels": {
"pan": "Kaydır",
@@ -20,7 +22,8 @@
"speedItem": "Hız {{index}}",
"annotationItem": "Açıklama",
"imageItem": "Görüntü",
"emptyText": "Boş metin"
"emptyText": "Boş metin",
"blurItem": "Bulanık {{index}}"
},
"emptyState": {
"noVideo": "Video Yüklenmedi",
+6
View File
@@ -101,6 +101,7 @@
"typeText": "文本",
"typeImage": "图片",
"typeArrow": "箭头",
"typeBlur": "模糊",
"textContent": "文本内容",
"textPlaceholder": "输入您的文本...",
"fontStyle": "字体样式",
@@ -117,6 +118,11 @@
"arrowDirection": "箭头方向",
"strokeWidth": "描边宽度:{{width}}px",
"arrowColor": "箭头颜色",
"blurShape": "模糊形状",
"blurIntensity": "模糊强度",
"blurShapeRectangle": "矩形",
"blurShapeOval": "椭圆",
"blurShapeFreehand": "自由手绘",
"deleteAnnotation": "删除标注",
"shortcutsAndTips": "快捷键与提示",
"tipMovePlayhead": "将播放头移动到重叠的标注区域并选择一个项目。",
+1
View File
@@ -18,6 +18,7 @@
"addTrim": "添加剪辑",
"addSpeed": "添加速度",
"addAnnotation": "添加标注",
"addBlur": "添加模糊",
"addKeyframe": "添加关键帧",
"deleteSelected": "删除所选",
"playPause": "播放 / 暂停"
+6 -3
View File
@@ -4,13 +4,15 @@
"suggestZooms": "根据光标建议缩放",
"addTrim": "添加剪辑 (T)",
"addAnnotation": "添加标注 (A)",
"addSpeed": "添加速度 (S)"
"addSpeed": "添加速度 (S)",
"addBlur": "添加模糊 (B)"
},
"hints": {
"pressZoom": "按 Z 添加缩放",
"pressTrim": "按 T 添加剪辑",
"pressAnnotation": "按 A 添加标注",
"pressSpeed": "按 S 添加速度"
"pressSpeed": "按 S 添加速度",
"pressBlur": "按 B 添加模糊区域"
},
"labels": {
"pan": "平移",
@@ -20,7 +22,8 @@
"speedItem": "速度 {{index}}",
"annotationItem": "标注",
"imageItem": "图片",
"emptyText": "空文本"
"emptyText": "空文本",
"blurItem": "模糊 {{index}}"
},
"emptyState": {
"noVideo": "未加载视频",
+101 -1
View File
@@ -1,4 +1,13 @@
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
import {
type AnnotationRegion,
type ArrowDirection,
DEFAULT_BLUR_INTENSITY,
MAX_BLUR_INTENSITY,
MIN_BLUR_INTENSITY,
} from "@/components/video-editor/types";
let blurScratchCanvas: HTMLCanvasElement | null = null;
let blurScratchCtx: CanvasRenderingContext2D | null = null;
// SVG path data for each arrow direction
const ARROW_PATHS: Record<ArrowDirection, string[]> = {
@@ -96,6 +105,93 @@ function renderArrow(
ctx.restore();
}
function drawBlurPath(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
) {
const shape = annotation.blurData?.shape || "rectangle";
if (shape === "rectangle") {
ctx.beginPath();
ctx.rect(x, y, width, height);
return;
}
if (shape === "oval") {
ctx.beginPath();
ctx.ellipse(x + width / 2, y + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
return;
}
const points = annotation.blurData?.freehandPoints;
if (shape === "freehand" && points && points.length >= 3) {
ctx.beginPath();
ctx.moveTo(x + (points[0].x / 100) * width, y + (points[0].y / 100) * height);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(x + (points[i].x / 100) * width, y + (points[i].y / 100) * height);
}
ctx.closePath();
return;
}
ctx.beginPath();
ctx.rect(x, y, width, height);
}
function renderBlur(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
x: number,
y: number,
width: number,
height: number,
scaleFactor: number,
) {
const canvas = ctx.canvas;
const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY;
const blurRadius = Math.max(
1,
Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor),
);
// Sample pixels around the target shape too; without this padding, small blur regions
// lose intensity because the filter has no neighboring pixels to blend with.
const samplePadding = Math.max(2, Math.ceil(blurRadius * 2));
const sx = Math.max(0, Math.floor(x) - samplePadding);
const sy = Math.max(0, Math.floor(y) - samplePadding);
const ex = Math.min(canvas.width, Math.ceil(x + width) + samplePadding);
const ey = Math.min(canvas.height, Math.ceil(y + height) + samplePadding);
const sw = Math.max(0, ex - sx);
const sh = Math.max(0, ey - sy);
if (sw <= 0 || sh <= 0) return;
if (!blurScratchCanvas || !blurScratchCtx) {
blurScratchCanvas = document.createElement("canvas");
blurScratchCtx = blurScratchCanvas.getContext("2d");
}
if (!blurScratchCanvas || !blurScratchCtx) return;
blurScratchCanvas.width = sw;
blurScratchCanvas.height = sh;
blurScratchCtx.clearRect(0, 0, sw, sh);
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
ctx.save();
drawBlurPath(ctx, annotation, x, y, width, height);
ctx.clip();
ctx.filter = `blur(${blurRadius}px)`;
ctx.drawImage(blurScratchCanvas, sx, sy);
ctx.filter = "none";
ctx.restore();
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function renderText(
ctx: CanvasRenderingContext2D,
annotation: AnnotationRegion,
@@ -304,6 +400,10 @@ export async function renderAnnotations(
);
}
break;
case "blur":
renderBlur(ctx, annotation, x, y, width, height, scaleFactor);
break;
}
}
}
+5 -1
View File
@@ -3,6 +3,7 @@ export const SHORTCUT_ACTIONS = [
"addTrim",
"addSpeed",
"addAnnotation",
"addBlur",
"addKeyframe",
"deleteSelected",
"playPause",
@@ -108,6 +109,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
addTrim: { key: "t" },
addSpeed: { key: "s" },
addAnnotation: { key: "a" },
addBlur: { key: "b" },
addKeyframe: { key: "f" },
deleteSelected: { key: "d", ctrl: true },
playPause: { key: " " },
@@ -118,6 +120,7 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
addTrim: "Add Trim",
addSpeed: "Add Speed",
addAnnotation: "Add Annotation",
addBlur: "Add Blur",
addKeyframe: "Add Keyframe",
deleteSelected: "Delete Selected",
playPause: "Play / Pause",
@@ -125,9 +128,10 @@ export const SHORTCUT_LABELS: Record<ShortcutAction, string> = {
export function matchesShortcut(
e: KeyboardEvent,
binding: ShortcutBinding,
binding: ShortcutBinding | undefined,
isMacPlatform: boolean,
): boolean {
if (!binding) return false;
if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;