feat: add mosaic blur with black shading
This commit is contained in:
@@ -1,15 +1,27 @@
|
||||
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
|
||||
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import {
|
||||
getBlurOverlayColor,
|
||||
getMosaicGridOverlayColor,
|
||||
getNormalizedMosaicBlockSize,
|
||||
} from "@/lib/blurEffects";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getArrowComponent } from "./ArrowSvgs";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurData,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
const FREEHAND_POINT_THRESHOLD = 1;
|
||||
type PreviewCanvasSource = {
|
||||
width: number;
|
||||
height: number;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
};
|
||||
|
||||
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length < 3) return undefined;
|
||||
@@ -36,6 +48,8 @@ interface AnnotationOverlayProps {
|
||||
onClick: (id: string) => void;
|
||||
zIndex: number;
|
||||
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
|
||||
previewSourceCanvas?: PreviewCanvasSource | null;
|
||||
previewFrameVersion?: number;
|
||||
}
|
||||
|
||||
export function AnnotationOverlay({
|
||||
@@ -50,11 +64,13 @@ export function AnnotationOverlay({
|
||||
onClick,
|
||||
zIndex,
|
||||
isSelectedBoost,
|
||||
previewSourceCanvas,
|
||||
previewFrameVersion,
|
||||
}: AnnotationOverlayProps) {
|
||||
const x = (annotation.position.x / 100) * containerWidth;
|
||||
const y = (annotation.position.y / 100) * containerHeight;
|
||||
const width = (annotation.size.width / 100) * containerWidth;
|
||||
const height = (annotation.size.height / 100) * containerHeight;
|
||||
const committedX = (annotation.position.x / 100) * containerWidth;
|
||||
const committedY = (annotation.position.y / 100) * containerHeight;
|
||||
const committedWidth = (annotation.size.width / 100) * containerWidth;
|
||||
const committedHeight = (annotation.size.height / 100) * containerHeight;
|
||||
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
|
||||
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
|
||||
const isDraggingRef = useRef(false);
|
||||
@@ -65,6 +81,108 @@ export function AnnotationOverlay({
|
||||
[],
|
||||
);
|
||||
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
|
||||
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
|
||||
const blurOverlayColor =
|
||||
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
|
||||
const mosaicGridOverlayColor =
|
||||
annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : "";
|
||||
const [liveRect, setLiveRect] = useState({
|
||||
x: committedX,
|
||||
y: committedY,
|
||||
width: committedWidth,
|
||||
height: committedHeight,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLiveRect({
|
||||
x: committedX,
|
||||
y: committedY,
|
||||
width: committedWidth,
|
||||
height: committedHeight,
|
||||
});
|
||||
}, [committedHeight, committedWidth, committedX, committedY]);
|
||||
|
||||
const { x, y, width, height } = liveRect;
|
||||
|
||||
useEffect(() => {
|
||||
if (annotation.type !== "blur" || blurType !== "mosaic") {
|
||||
return;
|
||||
}
|
||||
void previewFrameVersion;
|
||||
|
||||
const canvas = mosaicCanvasRef.current;
|
||||
const sourceCanvas = previewSourceCanvas;
|
||||
if (!canvas || !sourceCanvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceWidth = sourceCanvas.width;
|
||||
const sourceHeight = sourceCanvas.height;
|
||||
const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth;
|
||||
const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight;
|
||||
if (
|
||||
sourceWidth <= 0 ||
|
||||
sourceHeight <= 0 ||
|
||||
sourceClientWidth <= 0 ||
|
||||
sourceClientHeight <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawWidth = Math.max(1, Math.round(width));
|
||||
const drawHeight = Math.max(1, Math.round(height));
|
||||
if (drawWidth <= 0 || drawHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = drawWidth;
|
||||
canvas.height = drawHeight;
|
||||
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleX = sourceWidth / sourceClientWidth;
|
||||
const scaleY = sourceHeight / sourceClientHeight;
|
||||
const sourceX = Math.max(0, Math.floor(x * scaleX));
|
||||
const sourceY = Math.max(0, Math.floor(y * scaleY));
|
||||
const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX));
|
||||
const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY));
|
||||
const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX));
|
||||
const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY));
|
||||
const blockSize = getNormalizedMosaicBlockSize(annotation.blurData);
|
||||
const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize));
|
||||
const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize));
|
||||
canvas.width = downscaledWidth;
|
||||
canvas.height = downscaledHeight;
|
||||
|
||||
context.clearRect(0, 0, downscaledWidth, downscaledHeight);
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(
|
||||
sourceCanvas as CanvasImageSource,
|
||||
sourceX,
|
||||
sourceY,
|
||||
clampedSampleWidth,
|
||||
clampedSampleHeight,
|
||||
0,
|
||||
0,
|
||||
downscaledWidth,
|
||||
downscaledHeight,
|
||||
);
|
||||
}, [
|
||||
annotation,
|
||||
blurType,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
height,
|
||||
previewFrameVersion,
|
||||
previewSourceCanvas,
|
||||
width,
|
||||
x,
|
||||
y,
|
||||
]);
|
||||
|
||||
const renderArrow = () => {
|
||||
const direction = annotation.figureData?.arrowDirection || "right";
|
||||
@@ -240,6 +358,10 @@ export function AnnotationOverlay({
|
||||
1,
|
||||
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
|
||||
);
|
||||
const blockSize = Math.max(
|
||||
1,
|
||||
Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE),
|
||||
);
|
||||
const activeFreehandPoints =
|
||||
shape === "freehand"
|
||||
? isFreehandDrawing
|
||||
@@ -292,12 +414,43 @@ export function AnnotationOverlay({
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backdropFilter: `blur(${blurIntensity}px)`,
|
||||
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.02)",
|
||||
backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
|
||||
WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
|
||||
backgroundColor: blurOverlayColor,
|
||||
opacity: shouldShowFreehandBlurFill ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
|
||||
<canvas
|
||||
ref={mosaicCanvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
imageRendering: "pixelated",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backgroundColor: blurOverlayColor,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{blurType === "mosaic" && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
...shapeMaskStyle,
|
||||
backgroundImage: `linear-gradient(${mosaicGridOverlayColor} 1px, transparent 1px), linear-gradient(90deg, ${mosaicGridOverlayColor} 1px, transparent 1px)`,
|
||||
backgroundSize: `${blockSize}px ${blockSize}px`,
|
||||
mixBlendMode: "screen",
|
||||
opacity: 0.35,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isSelected && shape !== "freehand" && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
|
||||
@@ -354,7 +507,19 @@ export function AnnotationOverlay({
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onDrag={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
}}
|
||||
onDragStop={(_e, d) => {
|
||||
setLiveRect((prev) => ({
|
||||
...prev,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}));
|
||||
const xPercent = (d.x / containerWidth) * 100;
|
||||
const yPercent = (d.y / containerHeight) * 100;
|
||||
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
|
||||
@@ -364,7 +529,21 @@ export function AnnotationOverlay({
|
||||
isDraggingRef.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onResize={(_e, _direction, ref, _delta, position) => {
|
||||
setLiveRect({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
}}
|
||||
onResizeStop={(_e, _direction, ref, _delta, position) => {
|
||||
setLiveRect({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: ref.offsetWidth,
|
||||
height: ref.offsetHeight,
|
||||
});
|
||||
const xPercent = (position.x / containerWidth) * 100;
|
||||
const yPercent = (position.y / containerHeight) * 100;
|
||||
const widthPercent = (ref.offsetWidth / containerWidth) * 100;
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { Info, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
import { getBlurOverlayColor } from "@/lib/blurEffects";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type BlurColor,
|
||||
type BlurData,
|
||||
type BlurShape,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "./types";
|
||||
|
||||
@@ -31,6 +43,10 @@ export function BlurSettingsPanel({
|
||||
{ value: "rectangle", labelKey: "blurShapeRectangle" },
|
||||
{ value: "oval", labelKey: "blurShapeOval" },
|
||||
];
|
||||
const blurColorOptions: Array<{ value: BlurColor; labelKey: string }> = [
|
||||
{ value: "white", labelKey: "blurColorWhite" },
|
||||
{ value: "black", labelKey: "blurColorBlack" },
|
||||
];
|
||||
|
||||
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">
|
||||
@@ -91,27 +107,116 @@ export function BlurSettingsPanel({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||
{t("annotation.blurType")}
|
||||
</label>
|
||||
<Select
|
||||
value={blurRegion.blurData?.type ?? DEFAULT_BLUR_DATA.type}
|
||||
onValueChange={(value) => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
type: value === "mosaic" ? "mosaic" : "blur",
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-white/5 border-white/10 text-slate-200 h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1a1a1c] border-white/10 text-slate-200">
|
||||
<SelectItem value="blur">{t("annotation.blurTypeBlur")}</SelectItem>
|
||||
<SelectItem value="mosaic">{t("annotation.blurTypeMosaic")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-xs font-medium text-slate-300 mb-2 block">
|
||||
{t("annotation.blurColor")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{blurColorOptions.map((option) => {
|
||||
const activeColor = blurRegion.blurData?.color ?? DEFAULT_BLUR_DATA.color;
|
||||
const isActive = activeColor === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
const nextBlurData: BlurData = {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
color: option.value,
|
||||
};
|
||||
onBlurDataChange(nextBlurData);
|
||||
requestAnimationFrame(() => {
|
||||
onBlurDataCommit?.();
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"h-10 rounded-lg border flex items-center gap-2 px-3 transition-all",
|
||||
isActive
|
||||
? "bg-[#34B27B] border-[#34B27B]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-white/20"
|
||||
style={{
|
||||
backgroundColor: getBlurOverlayColor({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
color: option.value,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-slate-200">
|
||||
{t(`annotation.${option.labelKey}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</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")}
|
||||
{blurRegion.blurData?.type === "mosaic"
|
||||
? t("annotation.mosaicBlockSize")
|
||||
: t("annotation.blurIntensity")}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">
|
||||
{Math.round(blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity)}px
|
||||
{Math.round(
|
||||
blurRegion.blurData?.type === "mosaic"
|
||||
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||
)}
|
||||
px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity]}
|
||||
value={[
|
||||
blurRegion.blurData?.type === "mosaic"
|
||||
? (blurRegion.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE)
|
||||
: (blurRegion.blurData?.intensity ?? DEFAULT_BLUR_DATA.intensity),
|
||||
]}
|
||||
onValueChange={(values) => {
|
||||
onBlurDataChange({
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...blurRegion.blurData,
|
||||
intensity: values[0],
|
||||
...(blurRegion.blurData?.type === "mosaic"
|
||||
? { blockSize: values[0] }
|
||||
: { intensity: values[0] }),
|
||||
});
|
||||
}}
|
||||
onValueCommit={() => onBlurDataCommit?.()}
|
||||
min={MIN_BLUR_INTENSITY}
|
||||
max={MAX_BLUR_INTENSITY}
|
||||
min={blurRegion.blurData?.type === "mosaic" ? MIN_BLUR_BLOCK_SIZE : MIN_BLUR_INTENSITY}
|
||||
max={blurRegion.blurData?.type === "mosaic" ? MAX_BLUR_BLOCK_SIZE : MAX_BLUR_INTENSITY}
|
||||
step={1}
|
||||
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
|
||||
/>
|
||||
|
||||
@@ -1348,7 +1348,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
if (annotation.id === selectedAnnotationId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= annotation.startMs && timeMs <= annotation.endMs;
|
||||
return timeMs >= annotation.startMs && timeMs < annotation.endMs;
|
||||
});
|
||||
|
||||
const filteredBlurRegions = (blurRegions || []).filter((blurRegion) => {
|
||||
@@ -1358,7 +1358,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
if (blurRegion.id === selectedBlurId) return true;
|
||||
|
||||
const timeMs = Math.round(currentTime * 1000);
|
||||
return timeMs >= blurRegion.startMs && timeMs <= blurRegion.endMs;
|
||||
return timeMs >= blurRegion.startMs && timeMs < blurRegion.endMs;
|
||||
});
|
||||
|
||||
const sorted = [
|
||||
@@ -1371,6 +1371,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotCanvas = (() => {
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
try {
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// Handle click-through cycling: when clicking same annotation, cycle to next
|
||||
const handleAnnotationClick = (clickedId: string) => {
|
||||
@@ -1404,7 +1413,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
<AnnotationOverlay
|
||||
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}-${item.region.blurData?.type ?? "blur"}-${item.region.blurData?.shape ?? "rectangle"}-${item.region.blurData?.color ?? "white"}-${Math.round(item.region.blurData?.blockSize ?? 0)}-${Math.round(item.region.blurData?.intensity ?? 0)}-${(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}
|
||||
@@ -1438,6 +1447,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
? item.region.id === selectedBlurId
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
|
||||
@@ -68,6 +68,75 @@ describe("projectPersistence media compatibility", () => {
|
||||
).toBe("rectangle");
|
||||
});
|
||||
|
||||
it("normalizes blur region type and mosaic block size safely", () => {
|
||||
const editor = normalizeProjectEditor({
|
||||
annotationRegions: [
|
||||
{
|
||||
id: "annotation-1",
|
||||
startMs: 0,
|
||||
endMs: 500,
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 20, height: 20 },
|
||||
style: {
|
||||
color: "#fff",
|
||||
backgroundColor: "transparent",
|
||||
fontSize: 32,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
},
|
||||
zIndex: 1,
|
||||
blurData: {
|
||||
type: "mosaic",
|
||||
shape: "rectangle",
|
||||
color: "black",
|
||||
intensity: 999,
|
||||
blockSize: 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "annotation-2",
|
||||
startMs: 0,
|
||||
endMs: 500,
|
||||
type: "blur",
|
||||
content: "",
|
||||
position: { x: 10, y: 10 },
|
||||
size: { width: 20, height: 20 },
|
||||
style: {
|
||||
color: "#fff",
|
||||
backgroundColor: "transparent",
|
||||
fontSize: 32,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
},
|
||||
zIndex: 2,
|
||||
blurData: {
|
||||
type: "invalid" as never,
|
||||
shape: "rectangle",
|
||||
color: "invalid" as never,
|
||||
intensity: 10,
|
||||
blockSize: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(editor.annotationRegions[0].blurData?.type).toBe("mosaic");
|
||||
expect(editor.annotationRegions[0].blurData?.color).toBe("black");
|
||||
expect(editor.annotationRegions[0].blurData?.intensity).toBe(40);
|
||||
expect(editor.annotationRegions[0].blurData?.blockSize).toBe(48);
|
||||
expect(editor.annotationRegions[1].blurData?.type).toBe("blur");
|
||||
expect(editor.annotationRegions[1].blurData?.color).toBe("white");
|
||||
expect(editor.annotationRegions[1].blurData?.blockSize).toBe(4);
|
||||
});
|
||||
|
||||
it("accepts the dual frame webcam layout preset", () => {
|
||||
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
|
||||
"dual-frame",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeBlurColor, normalizeBlurType } from "@/lib/blurEffects";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { normalizeProjectMedia } from "@/lib/recordingSession";
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_DATA,
|
||||
DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
@@ -20,8 +22,10 @@ import {
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MAX_PLAYBACK_SPEED,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
MIN_PLAYBACK_SPEED,
|
||||
type SpeedRegion,
|
||||
@@ -305,6 +309,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
VALID_BLUR_SHAPES.has(region.blurData.shape)
|
||||
? region.blurData.shape
|
||||
: DEFAULT_BLUR_DATA.shape;
|
||||
const blurType = normalizeBlurType(region.blurData?.type);
|
||||
const blurColor = normalizeBlurColor(region.blurData?.color);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
@@ -365,10 +371,15 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
? {
|
||||
...DEFAULT_BLUR_DATA,
|
||||
...region.blurData,
|
||||
type: blurType,
|
||||
shape: blurShape,
|
||||
color: blurColor,
|
||||
intensity: isFiniteNumber(region.blurData.intensity)
|
||||
? clamp(region.blurData.intensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY)
|
||||
: DEFAULT_BLUR_INTENSITY,
|
||||
blockSize: isFiniteNumber(region.blurData.blockSize)
|
||||
? clamp(region.blurData.blockSize, MIN_BLUR_BLOCK_SIZE, MAX_BLUR_BLOCK_SIZE)
|
||||
: DEFAULT_BLUR_BLOCK_SIZE,
|
||||
freehandPoints: Array.isArray(region.blurData.freehandPoints)
|
||||
? region.blurData.freehandPoints
|
||||
.filter(
|
||||
|
||||
@@ -68,14 +68,22 @@ export interface FigureData {
|
||||
}
|
||||
|
||||
export type BlurShape = "rectangle" | "oval" | "freehand";
|
||||
export type BlurType = "blur" | "mosaic";
|
||||
export type BlurColor = "white" | "black";
|
||||
|
||||
export const MIN_BLUR_INTENSITY = 2;
|
||||
export const MAX_BLUR_INTENSITY = 40;
|
||||
export const DEFAULT_BLUR_INTENSITY = 12;
|
||||
export const MIN_BLUR_BLOCK_SIZE = 4;
|
||||
export const MAX_BLUR_BLOCK_SIZE = 48;
|
||||
export const DEFAULT_BLUR_BLOCK_SIZE = 12;
|
||||
|
||||
export interface BlurData {
|
||||
type: BlurType;
|
||||
shape: BlurShape;
|
||||
color: BlurColor;
|
||||
intensity: number;
|
||||
blockSize: number;
|
||||
// Points are normalized (0-100) within the annotation bounds.
|
||||
freehandPoints?: Array<{ x: number; y: number }>;
|
||||
}
|
||||
@@ -157,8 +165,11 @@ export const DEFAULT_BLUR_FREEHAND_POINTS: Array<{ x: number; y: number }> = [
|
||||
];
|
||||
|
||||
export const DEFAULT_BLUR_DATA: BlurData = {
|
||||
type: "blur",
|
||||
shape: "rectangle",
|
||||
color: "white",
|
||||
intensity: DEFAULT_BLUR_INTENSITY,
|
||||
blockSize: DEFAULT_BLUR_BLOCK_SIZE,
|
||||
freehandPoints: DEFAULT_BLUR_FREEHAND_POINTS,
|
||||
};
|
||||
|
||||
|
||||
@@ -126,8 +126,15 @@
|
||||
"arrowDirection": "Arrow Direction",
|
||||
"strokeWidth": "Stroke Width: {{width}}px",
|
||||
"arrowColor": "Arrow Color",
|
||||
"blurType": "Blur Type",
|
||||
"blurTypeBlur": "Blur",
|
||||
"blurTypeMosaic": "Mosaic Blur",
|
||||
"blurColor": "Blur Color",
|
||||
"blurColorWhite": "White",
|
||||
"blurColorBlack": "Black",
|
||||
"blurShape": "Blur Shape",
|
||||
"blurIntensity": "Blur Intensity",
|
||||
"mosaicBlockSize": "Mosaic Block Size",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Oval",
|
||||
"blurShapeFreehand": "Freehand",
|
||||
|
||||
@@ -126,8 +126,15 @@
|
||||
"arrowDirection": "Dirección de la flecha",
|
||||
"strokeWidth": "Grosor del trazo: {{width}}px",
|
||||
"arrowColor": "Color de la flecha",
|
||||
"blurType": "Tipo de desenfoque",
|
||||
"blurTypeBlur": "Desenfoque",
|
||||
"blurTypeMosaic": "Desenfoque mosaico",
|
||||
"blurColor": "Color del desenfoque",
|
||||
"blurColorWhite": "Blanco",
|
||||
"blurColorBlack": "Negro",
|
||||
"blurShape": "Forma del desenfoque",
|
||||
"blurIntensity": "Intensidad del desenfoque",
|
||||
"mosaicBlockSize": "Tamano del bloque mosaico",
|
||||
"blurShapeRectangle": "Rectángulo",
|
||||
"blurShapeOval": "Óvalo",
|
||||
"blurShapeFreehand": "Mano alzada",
|
||||
|
||||
@@ -115,8 +115,15 @@
|
||||
"arrowDirection": "Direction de la flèche",
|
||||
"strokeWidth": "Épaisseur du trait : {{width}}px",
|
||||
"arrowColor": "Couleur de la flèche",
|
||||
"blurType": "Type de flou",
|
||||
"blurTypeBlur": "Flou",
|
||||
"blurTypeMosaic": "Flou mosaique",
|
||||
"blurColor": "Couleur du flou",
|
||||
"blurColorWhite": "Blanc",
|
||||
"blurColorBlack": "Noir",
|
||||
"blurShape": "Forme du flou",
|
||||
"blurIntensity": "Intensité du flou",
|
||||
"mosaicBlockSize": "Taille des blocs de mosaique",
|
||||
"blurShapeRectangle": "Rectangle",
|
||||
"blurShapeOval": "Ovale",
|
||||
"blurShapeFreehand": "Main levée",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyMosaicToImageData, getBlurOverlayColor, normalizeBlurColor } from "./blurEffects";
|
||||
|
||||
function createTestImageData(width: number, height: number) {
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const offset = (y * width + x) * 4;
|
||||
data[offset] = x * 20 + y;
|
||||
data[offset + 1] = y * 20 + x;
|
||||
data[offset + 2] = (x + y) * 10;
|
||||
data[offset + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
} as ImageData;
|
||||
}
|
||||
|
||||
describe("applyMosaicToImageData", () => {
|
||||
it("collapses each block to a single representative color", () => {
|
||||
const imageData = createTestImageData(4, 4);
|
||||
const original = new Uint8ClampedArray(imageData.data);
|
||||
|
||||
applyMosaicToImageData(imageData, 2);
|
||||
|
||||
const topLeft = Array.from(imageData.data.slice(0, 4));
|
||||
const topRightOffset = (1 * 4 + 1) * 4;
|
||||
const topRight = Array.from(imageData.data.slice(topRightOffset, topRightOffset + 4));
|
||||
expect(topLeft).toEqual(topRight);
|
||||
|
||||
expect(Array.from(original.slice(0, 4))).not.toEqual(topLeft);
|
||||
});
|
||||
|
||||
it("reduces unique pixel colors, making the transform information-lossy", () => {
|
||||
const imageData = createTestImageData(8, 8);
|
||||
const before = new Set<string>();
|
||||
const after = new Set<string>();
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
before.add(
|
||||
`${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
|
||||
);
|
||||
}
|
||||
|
||||
applyMosaicToImageData(imageData, 4);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
after.add(
|
||||
`${imageData.data[i]}-${imageData.data[i + 1]}-${imageData.data[i + 2]}-${imageData.data[i + 3]}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(after.size).toBeLessThan(before.size);
|
||||
expect(after.size).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blur color helpers", () => {
|
||||
it("normalizes invalid blur colors to white", () => {
|
||||
expect(normalizeBlurColor("black")).toBe("black");
|
||||
expect(normalizeBlurColor("invalid")).toBe("white");
|
||||
});
|
||||
|
||||
it("returns a dark overlay when black blur color is selected", () => {
|
||||
expect(
|
||||
getBlurOverlayColor({
|
||||
type: "blur",
|
||||
shape: "rectangle",
|
||||
color: "black",
|
||||
intensity: 12,
|
||||
blockSize: 12,
|
||||
}),
|
||||
).toBe("rgba(0, 0, 0, 0.18)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
type BlurColor,
|
||||
type BlurData,
|
||||
type BlurType,
|
||||
DEFAULT_BLUR_BLOCK_SIZE,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_INTENSITY,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_INTENSITY,
|
||||
} from "@/components/video-editor/types";
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function normalizeBlurType(value: unknown): BlurType {
|
||||
return value === "mosaic" ? "mosaic" : "blur";
|
||||
}
|
||||
|
||||
export function normalizeBlurColor(value: unknown): BlurColor {
|
||||
return value === "black" ? "black" : "white";
|
||||
}
|
||||
|
||||
export function getNormalizedBlurIntensity(blurData?: BlurData | null): number {
|
||||
return clamp(
|
||||
blurData?.intensity ?? DEFAULT_BLUR_INTENSITY,
|
||||
MIN_BLUR_INTENSITY,
|
||||
MAX_BLUR_INTENSITY,
|
||||
);
|
||||
}
|
||||
|
||||
export function getNormalizedMosaicBlockSize(blurData?: BlurData | null, scaleFactor = 1): number {
|
||||
const rawBlockSize = clamp(
|
||||
blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE,
|
||||
MIN_BLUR_BLOCK_SIZE,
|
||||
MAX_BLUR_BLOCK_SIZE,
|
||||
);
|
||||
return Math.max(1, Math.round(rawBlockSize * Math.max(scaleFactor, 0.01)));
|
||||
}
|
||||
|
||||
export function getBlurOverlayColor(blurData?: BlurData | null): string {
|
||||
const blurColor = normalizeBlurColor(blurData?.color);
|
||||
const blurType = normalizeBlurType(blurData?.type);
|
||||
|
||||
if (blurColor === "black") {
|
||||
return blurType === "mosaic" ? "rgba(0, 0, 0, 0.72)" : "rgba(0, 0, 0, 0.56)";
|
||||
}
|
||||
|
||||
return blurType === "mosaic" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.02)";
|
||||
}
|
||||
|
||||
export function getMosaicGridOverlayColor(blurData?: BlurData | null): string {
|
||||
return normalizeBlurColor(blurData?.color) === "black"
|
||||
? "rgba(255,255,255,0.05)"
|
||||
: "rgba(255,255,255,0.04)";
|
||||
}
|
||||
|
||||
export function applyMosaicToImageData(imageData: ImageData, blockSize: number): ImageData {
|
||||
const width = imageData.width;
|
||||
const height = imageData.height;
|
||||
const data = imageData.data;
|
||||
const normalizedBlockSize = Math.max(1, Math.floor(blockSize));
|
||||
|
||||
if (width <= 0 || height <= 0 || normalizedBlockSize <= 1) {
|
||||
return imageData;
|
||||
}
|
||||
|
||||
for (let blockY = 0; blockY < height; blockY += normalizedBlockSize) {
|
||||
for (let blockX = 0; blockX < width; blockX += normalizedBlockSize) {
|
||||
const blockWidth = Math.min(normalizedBlockSize, width - blockX);
|
||||
const blockHeight = Math.min(normalizedBlockSize, height - blockY);
|
||||
const pixelCount = blockWidth * blockHeight;
|
||||
|
||||
if (pixelCount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let redTotal = 0;
|
||||
let greenTotal = 0;
|
||||
let blueTotal = 0;
|
||||
let alphaTotal = 0;
|
||||
|
||||
for (let y = blockY; y < blockY + blockHeight; y++) {
|
||||
for (let x = blockX; x < blockX + blockWidth; x++) {
|
||||
const offset = (y * width + x) * 4;
|
||||
redTotal += data[offset];
|
||||
greenTotal += data[offset + 1];
|
||||
blueTotal += data[offset + 2];
|
||||
alphaTotal += data[offset + 3];
|
||||
}
|
||||
}
|
||||
|
||||
const averageRed = Math.round(redTotal / pixelCount);
|
||||
const averageGreen = Math.round(greenTotal / pixelCount);
|
||||
const averageBlue = Math.round(blueTotal / pixelCount);
|
||||
const averageAlpha = Math.round(alphaTotal / pixelCount);
|
||||
|
||||
for (let y = blockY; y < blockY + blockHeight; y++) {
|
||||
for (let x = blockX; x < blockX + blockWidth; x++) {
|
||||
const offset = (y * width + x) * 4;
|
||||
data[offset] = averageRed;
|
||||
data[offset + 1] = averageGreen;
|
||||
data[offset + 2] = averageBlue;
|
||||
data[offset + 3] = averageAlpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imageData;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { type AnnotationRegion, type 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";
|
||||
applyMosaicToImageData,
|
||||
getBlurOverlayColor,
|
||||
getNormalizedBlurIntensity,
|
||||
getNormalizedMosaicBlockSize,
|
||||
normalizeBlurType,
|
||||
} from "@/lib/blurEffects";
|
||||
|
||||
let blurScratchCanvas: HTMLCanvasElement | null = null;
|
||||
let blurScratchCtx: CanvasRenderingContext2D | null = null;
|
||||
@@ -151,15 +152,16 @@ function renderBlur(
|
||||
scaleFactor: number,
|
||||
) {
|
||||
const canvas = ctx.canvas;
|
||||
const configuredIntensity = annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY;
|
||||
const blurType = normalizeBlurType(annotation.blurData?.type);
|
||||
|
||||
const blurRadius = Math.max(
|
||||
1,
|
||||
Math.round(clamp(configuredIntensity, MIN_BLUR_INTENSITY, MAX_BLUR_INTENSITY) * scaleFactor),
|
||||
Math.round(getNormalizedBlurIntensity(annotation.blurData) * 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 samplePadding =
|
||||
blurType === "mosaic"
|
||||
? Math.max(0, Math.ceil(getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor)))
|
||||
: 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);
|
||||
@@ -179,19 +181,26 @@ function renderBlur(
|
||||
blurScratchCtx.clearRect(0, 0, sw, sh);
|
||||
blurScratchCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
|
||||
if (blurType === "mosaic") {
|
||||
const imageData = blurScratchCtx.getImageData(0, 0, sw, sh);
|
||||
applyMosaicToImageData(
|
||||
imageData,
|
||||
getNormalizedMosaicBlockSize(annotation.blurData, scaleFactor),
|
||||
);
|
||||
blurScratchCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
drawBlurPath(ctx, annotation, x, y, width, height);
|
||||
ctx.clip();
|
||||
ctx.filter = `blur(${blurRadius}px)`;
|
||||
ctx.filter = blurType === "mosaic" ? "none" : `blur(${blurRadius}px)`;
|
||||
ctx.drawImage(blurScratchCanvas, sx, sy);
|
||||
ctx.filter = "none";
|
||||
ctx.fillStyle = getBlurOverlayColor(annotation.blurData);
|
||||
ctx.fillRect(sx, sy, sw, sh);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function renderText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
annotation: AnnotationRegion,
|
||||
@@ -364,7 +373,7 @@ export async function renderAnnotations(
|
||||
): Promise<void> {
|
||||
// Filter active annotations at current time
|
||||
const activeAnnotations = annotations.filter(
|
||||
(ann) => currentTimeMs >= ann.startMs && currentTimeMs <= ann.endMs,
|
||||
(ann) => currentTimeMs >= ann.startMs && currentTimeMs < ann.endMs,
|
||||
);
|
||||
|
||||
// Sort by z-index (lower first, so higher z-index draws on top)
|
||||
|
||||
Reference in New Issue
Block a user