Skip to content

Commit 8bcce47

Browse files
committed
feat: add mosaic blur with black shading
1 parent a6ae0e6 commit 8bcce47

12 files changed

Lines changed: 644 additions & 35 deletions

src/components/video-editor/AnnotationOverlay.tsx

Lines changed: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1-
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
1+
import { type CSSProperties, type PointerEvent, useEffect, useRef, useState } from "react";
22
import { Rnd } from "react-rnd";
3+
import {
4+
getBlurOverlayColor,
5+
getMosaicGridOverlayColor,
6+
getNormalizedMosaicBlockSize,
7+
} from "@/lib/blurEffects";
38
import { cn } from "@/lib/utils";
49
import { getArrowComponent } from "./ArrowSvgs";
510
import {
611
type AnnotationRegion,
712
type BlurData,
13+
DEFAULT_BLUR_BLOCK_SIZE,
814
DEFAULT_BLUR_DATA,
915
DEFAULT_BLUR_INTENSITY,
1016
} from "./types";
1117

1218
const FREEHAND_POINT_THRESHOLD = 1;
19+
type PreviewCanvasSource = {
20+
width: number;
21+
height: number;
22+
clientWidth?: number;
23+
clientHeight?: number;
24+
};
1325

1426
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
1527
if (points.length < 3) return undefined;
@@ -36,6 +48,8 @@ interface AnnotationOverlayProps {
3648
onClick: (id: string) => void;
3749
zIndex: number;
3850
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
51+
previewSourceCanvas?: PreviewCanvasSource | null;
52+
previewFrameVersion?: number;
3953
}
4054

4155
export function AnnotationOverlay({
@@ -50,11 +64,13 @@ export function AnnotationOverlay({
5064
onClick,
5165
zIndex,
5266
isSelectedBoost,
67+
previewSourceCanvas,
68+
previewFrameVersion,
5369
}: AnnotationOverlayProps) {
54-
const x = (annotation.position.x / 100) * containerWidth;
55-
const y = (annotation.position.y / 100) * containerHeight;
56-
const width = (annotation.size.width / 100) * containerWidth;
57-
const height = (annotation.size.height / 100) * containerHeight;
70+
const committedX = (annotation.position.x / 100) * containerWidth;
71+
const committedY = (annotation.position.y / 100) * containerHeight;
72+
const committedWidth = (annotation.size.width / 100) * containerWidth;
73+
const committedHeight = (annotation.size.height / 100) * containerHeight;
5874
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
5975
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
6076
const isDraggingRef = useRef(false);
@@ -65,6 +81,108 @@ export function AnnotationOverlay({
6581
[],
6682
);
6783
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
84+
const mosaicCanvasRef = useRef<HTMLCanvasElement | null>(null);
85+
const blurType = annotation.type === "blur" ? (annotation.blurData?.type ?? "blur") : "blur";
86+
const blurOverlayColor =
87+
annotation.type === "blur" ? getBlurOverlayColor(annotation.blurData) : "";
88+
const mosaicGridOverlayColor =
89+
annotation.type === "blur" ? getMosaicGridOverlayColor(annotation.blurData) : "";
90+
const [liveRect, setLiveRect] = useState({
91+
x: committedX,
92+
y: committedY,
93+
width: committedWidth,
94+
height: committedHeight,
95+
});
96+
97+
useEffect(() => {
98+
setLiveRect({
99+
x: committedX,
100+
y: committedY,
101+
width: committedWidth,
102+
height: committedHeight,
103+
});
104+
}, [committedHeight, committedWidth, committedX, committedY]);
105+
106+
const { x, y, width, height } = liveRect;
107+
108+
useEffect(() => {
109+
if (annotation.type !== "blur" || blurType !== "mosaic") {
110+
return;
111+
}
112+
void previewFrameVersion;
113+
114+
const canvas = mosaicCanvasRef.current;
115+
const sourceCanvas = previewSourceCanvas;
116+
if (!canvas || !sourceCanvas) {
117+
return;
118+
}
119+
120+
const sourceWidth = sourceCanvas.width;
121+
const sourceHeight = sourceCanvas.height;
122+
const sourceClientWidth = sourceCanvas.clientWidth || containerWidth || sourceWidth;
123+
const sourceClientHeight = sourceCanvas.clientHeight || containerHeight || sourceHeight;
124+
if (
125+
sourceWidth <= 0 ||
126+
sourceHeight <= 0 ||
127+
sourceClientWidth <= 0 ||
128+
sourceClientHeight <= 0
129+
) {
130+
return;
131+
}
132+
133+
const drawWidth = Math.max(1, Math.round(width));
134+
const drawHeight = Math.max(1, Math.round(height));
135+
if (drawWidth <= 0 || drawHeight <= 0) {
136+
return;
137+
}
138+
139+
canvas.width = drawWidth;
140+
canvas.height = drawHeight;
141+
142+
const context = canvas.getContext("2d", { willReadFrequently: true });
143+
if (!context) {
144+
return;
145+
}
146+
147+
const scaleX = sourceWidth / sourceClientWidth;
148+
const scaleY = sourceHeight / sourceClientHeight;
149+
const sourceX = Math.max(0, Math.floor(x * scaleX));
150+
const sourceY = Math.max(0, Math.floor(y * scaleY));
151+
const sourceSampleWidth = Math.max(1, Math.ceil(drawWidth * scaleX));
152+
const sourceSampleHeight = Math.max(1, Math.ceil(drawHeight * scaleY));
153+
const clampedSampleWidth = Math.max(1, Math.min(sourceSampleWidth, sourceWidth - sourceX));
154+
const clampedSampleHeight = Math.max(1, Math.min(sourceSampleHeight, sourceHeight - sourceY));
155+
const blockSize = getNormalizedMosaicBlockSize(annotation.blurData);
156+
const downscaledWidth = Math.max(1, Math.round(drawWidth / blockSize));
157+
const downscaledHeight = Math.max(1, Math.round(drawHeight / blockSize));
158+
canvas.width = downscaledWidth;
159+
canvas.height = downscaledHeight;
160+
161+
context.clearRect(0, 0, downscaledWidth, downscaledHeight);
162+
context.imageSmoothingEnabled = true;
163+
context.drawImage(
164+
sourceCanvas as CanvasImageSource,
165+
sourceX,
166+
sourceY,
167+
clampedSampleWidth,
168+
clampedSampleHeight,
169+
0,
170+
0,
171+
downscaledWidth,
172+
downscaledHeight,
173+
);
174+
}, [
175+
annotation,
176+
blurType,
177+
containerHeight,
178+
containerWidth,
179+
height,
180+
previewFrameVersion,
181+
previewSourceCanvas,
182+
width,
183+
x,
184+
y,
185+
]);
68186

69187
const renderArrow = () => {
70188
const direction = annotation.figureData?.arrowDirection || "right";
@@ -240,6 +358,10 @@ export function AnnotationOverlay({
240358
1,
241359
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
242360
);
361+
const blockSize = Math.max(
362+
1,
363+
Math.round(annotation.blurData?.blockSize ?? DEFAULT_BLUR_BLOCK_SIZE),
364+
);
243365
const activeFreehandPoints =
244366
shape === "freehand"
245367
? isFreehandDrawing
@@ -292,12 +414,43 @@ export function AnnotationOverlay({
292414
className="absolute inset-0"
293415
style={{
294416
...shapeMaskStyle,
295-
backdropFilter: `blur(${blurIntensity}px)`,
296-
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
297-
backgroundColor: "rgba(255, 255, 255, 0.02)",
417+
backdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
418+
WebkitBackdropFilter: blurType === "mosaic" ? "none" : `blur(${blurIntensity}px)`,
419+
backgroundColor: blurOverlayColor,
298420
opacity: shouldShowFreehandBlurFill ? 1 : 0,
299421
}}
300422
/>
423+
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
424+
<canvas
425+
ref={mosaicCanvasRef}
426+
className="absolute inset-0 w-full h-full"
427+
style={{
428+
...shapeMaskStyle,
429+
imageRendering: "pixelated",
430+
}}
431+
/>
432+
)}
433+
{blurType === "mosaic" && shouldShowFreehandBlurFill && (
434+
<div
435+
className="absolute inset-0 pointer-events-none"
436+
style={{
437+
...shapeMaskStyle,
438+
backgroundColor: blurOverlayColor,
439+
}}
440+
/>
441+
)}
442+
{blurType === "mosaic" && (
443+
<div
444+
className="absolute inset-0 pointer-events-none"
445+
style={{
446+
...shapeMaskStyle,
447+
backgroundImage: `linear-gradient(${mosaicGridOverlayColor} 1px, transparent 1px), linear-gradient(90deg, ${mosaicGridOverlayColor} 1px, transparent 1px)`,
448+
backgroundSize: `${blockSize}px ${blockSize}px`,
449+
mixBlendMode: "screen",
450+
opacity: 0.35,
451+
}}
452+
/>
453+
)}
301454
{isSelected && shape !== "freehand" && (
302455
<div
303456
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
@@ -354,7 +507,19 @@ export function AnnotationOverlay({
354507
onDragStart={() => {
355508
isDraggingRef.current = true;
356509
}}
510+
onDrag={(_e, d) => {
511+
setLiveRect((prev) => ({
512+
...prev,
513+
x: d.x,
514+
y: d.y,
515+
}));
516+
}}
357517
onDragStop={(_e, d) => {
518+
setLiveRect((prev) => ({
519+
...prev,
520+
x: d.x,
521+
y: d.y,
522+
}));
358523
const xPercent = (d.x / containerWidth) * 100;
359524
const yPercent = (d.y / containerHeight) * 100;
360525
onPositionChange(annotation.id, { x: xPercent, y: yPercent });
@@ -364,7 +529,21 @@ export function AnnotationOverlay({
364529
isDraggingRef.current = false;
365530
}, 100);
366531
}}
532+
onResize={(_e, _direction, ref, _delta, position) => {
533+
setLiveRect({
534+
x: position.x,
535+
y: position.y,
536+
width: ref.offsetWidth,
537+
height: ref.offsetHeight,
538+
});
539+
}}
367540
onResizeStop={(_e, _direction, ref, _delta, position) => {
541+
setLiveRect({
542+
x: position.x,
543+
y: position.y,
544+
width: ref.offsetWidth,
545+
height: ref.offsetHeight,
546+
});
368547
const xPercent = (position.x / containerWidth) * 100;
369548
const yPercent = (position.y / containerHeight) * 100;
370549
const widthPercent = (ref.offsetWidth / containerWidth) * 100;

0 commit comments

Comments
 (0)