Merge pull request #210 from mahdyarief/feat/blur-annotation

feat(blur): implement blur annotation support
This commit is contained in:
webadderall
2026-04-11 20:21:28 +10:00
committed by GitHub
13 changed files with 260 additions and 20 deletions
@@ -1,6 +1,7 @@
import { useRef } from "react";
import { Rnd } from "react-rnd";
import type { AnnotationRegion } from "./types";
import { BLUR_ANNOTATION_STRENGTH, BASE_PREVIEW_WIDTH, type AnnotationRegion } from "./types";
import { cn } from "@/lib/utils";
import { getArrowComponent } from "./ArrowSvgs";
@@ -111,6 +112,25 @@ export function AnnotationOverlay({
</div>
);
case "blur": {
const previewScaleFactor = containerWidth / BASE_PREVIEW_WIDTH;
const currentBlurStrength = annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH;
const blurPx = currentBlurStrength * previewScaleFactor;
const blurStyle = `blur(${blurPx}px)`;
return (
<div
className="h-full w-full bg-slate-400/10"
style={{
backdropFilter: blurStyle,
WebkitBackdropFilter: blurStyle,
backgroundColor: annotation.blurColor || "transparent",
borderRadius: `${(annotation.style.borderRadius ?? 0) * previewScaleFactor}px`,
}}
/>
);
}
default:
return null;
}
@@ -1,7 +1,7 @@
import { useRef, useState, useEffect, useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info } from "lucide-react";
import { Trash2, Type, Image as ImageIcon, Upload, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, ChevronDown, Info, SquareDashed } from "lucide-react";
import { toast } from "sonner";
import Block from '@uiw/react-color-block';
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
@@ -21,6 +21,8 @@ interface AnnotationSettingsPanelProps {
onTypeChange: (type: AnnotationType) => void;
onStyleChange: (style: Partial<AnnotationRegion['style']>) => void;
onFigureDataChange?: (figureData: FigureData) => void;
onBlurIntensityChange?: (intensity: number) => void;
onBlurColorChange?: (color: string) => void;
onDelete: () => void;
}
@@ -43,6 +45,8 @@ export function AnnotationSettingsPanel({
onTypeChange,
onStyleChange,
onFigureDataChange,
onBlurIntensityChange,
onBlurColorChange,
onDelete,
}: AnnotationSettingsPanelProps) {
const t = useScopedT('editor');
@@ -128,7 +132,7 @@ export function AnnotationSettingsPanel({
{/* Type Selector */}
<Tabs value={annotation.type} onValueChange={(value) => onTypeChange(value as AnnotationType)} className="mb-6">
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-3 h-auto rounded-xl">
<TabsList className="mb-4 bg-white/5 border border-white/5 p-1 w-full grid grid-cols-4 h-auto rounded-xl">
<TabsTrigger value="text" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<Type className="w-4 h-4" />
{t('annotations.text')}
@@ -143,6 +147,10 @@ export function AnnotationSettingsPanel({
</svg>
{t('annotations.arrow')}
</TabsTrigger>
<TabsTrigger value="blur" className="data-[state=active]:bg-[#2563EB] data-[state=active]:text-white text-slate-400 py-2 rounded-lg transition-all gap-2">
<SquareDashed className="w-4 h-4" />
{t('annotations.blur')}
</TabsTrigger>
</TabsList>
{/* Text Content */}
@@ -499,6 +507,95 @@ export function AnnotationSettingsPanel({
</Popover>
</div>
</TabsContent>
<TabsContent value="blur" className="mt-0 space-y-4">
<div className="p-4 bg-white/5 rounded-xl border border-white/10 flex flex-col items-center">
<div className="w-full space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-200">
{t('annotations.blurStrength', undefined, { strength: annotation.blurIntensity ?? 20 })}
</span>
</div>
<Slider
value={[annotation.blurIntensity ?? 20]}
onValueChange={([value]) => onBlurIntensityChange?.(value)}
min={1}
max={100}
step={1}
className="w-full"
/>
</div>
<div className="w-full space-y-3 mt-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-200">
{t('annotations.solidColor', 'Solid Color (Censorship)')}
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => onBlurColorChange?.('')}
className={cn(
"w-8 h-8 rounded-full border-2 flex items-center justify-center transition-all",
!annotation.blurColor || annotation.blurColor === 'transparent' ? "border-[#2563EB] scale-110" : "border-transparent hover:border-white/20"
)}
title={t('annotations.none', 'None')}
>
<div className="w-5 h-5 rounded-full bg-slate-800 flex items-center justify-center overflow-hidden relative">
<div className="absolute w-full h-0.5 bg-red-500 rotate-45" />
</div>
</button>
<button
onClick={() => onBlurColorChange?.('#000000')}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all bg-black",
annotation.blurColor === '#000000' ? "border-[#2563EB] scale-110" : "border-transparent hover:border-white/20"
)}
title="Black"
/>
<button
onClick={() => onBlurColorChange?.('#FFFFFF')}
className={cn(
"w-8 h-8 rounded-full border-2 transition-all bg-white",
annotation.blurColor === '#FFFFFF' ? "border-[#2563EB] scale-110" : "border-transparent hover:border-white/20"
)}
title="White"
/>
<Popover>
<PopoverTrigger asChild>
<button
className={cn(
"w-8 h-8 rounded-full border-2 transition-all flex items-center justify-center overflow-hidden relative",
annotation.blurColor && !['#000000', '#FFFFFF', 'transparent', ''].includes(annotation.blurColor) ? "border-[#2563EB] scale-110" : "border-transparent hover:border-white/20"
)}
style={{ backgroundColor: (annotation.blurColor && !['#000000', '#FFFFFF', 'transparent', ''].includes(annotation.blurColor)) ? annotation.blurColor : 'transparent' }}
title="Custom Color"
>
{(!annotation.blurColor || ['#000000', '#FFFFFF', 'transparent', ''].includes(annotation.blurColor)) && (
<div className="w-full h-full flex items-center justify-center bg-white/5">
<div className="w-full h-full bg-gradient-to-tr from-red-500 via-green-500 to-blue-500 opacity-50" />
</div>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl">
<Block
color={annotation.blurColor || '#2563EB'}
colors={colorPalette}
onChange={(color) => {
onBlurColorChange?.(color.hex);
}}
style={{
borderRadius: '8px',
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</TabsContent>
</Tabs>
<Button
@@ -223,6 +223,8 @@ interface SettingsPanelProps {
onAnnotationTypeChange?: (id: string, type: AnnotationType) => void;
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion["style"]>) => void;
onAnnotationFigureDataChange?: (id: string, figureData: FigureData) => void;
onAnnotationBlurIntensityChange?: (id: string, intensity: number) => void;
onAnnotationBlurColorChange?: (id: string, color: string) => void;
onAnnotationDelete?: (id: string) => void;
autoCaptions?: CaptionCue[];
autoCaptionSettings?: AutoCaptionSettings;
@@ -570,6 +572,8 @@ export function SettingsPanel({
onAnnotationTypeChange,
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationBlurIntensityChange,
onAnnotationBlurColorChange,
onAnnotationDelete,
autoCaptions = [],
autoCaptionSettings = DEFAULT_AUTO_CAPTION_SETTINGS,
@@ -1301,6 +1305,16 @@ export function SettingsPanel({
? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData)
: undefined
}
onBlurIntensityChange={
onAnnotationBlurIntensityChange
? (intensity) => onAnnotationBlurIntensityChange(selectedAnnotation.id, intensity)
: undefined
}
onBlurColorChange={
onAnnotationBlurColorChange
? (color) => onAnnotationBlurColorChange(selectedAnnotation.id, color)
: undefined
}
onDelete={() => onAnnotationDelete(selectedAnnotation.id)}
/>
);
@@ -2689,6 +2689,11 @@ export default function VideoEditor() {
if (!region.figureData) {
updatedRegion.figureData = { ...DEFAULT_FIGURE_DATA };
}
} else if (type === "blur") {
updatedRegion.content = "";
if (region.blurIntensity === undefined) {
updatedRegion.blurIntensity = 20;
}
}
return updatedRegion;
@@ -2714,6 +2719,18 @@ export default function VideoEditor() {
);
}, []);
const handleAnnotationBlurIntensityChange = useCallback((id: string, blurIntensity: number) => {
setAnnotationRegions((prev) =>
prev.map((region) => (region.id === id ? { ...region, blurIntensity } : region)),
);
}, []);
const handleAnnotationBlurColorChange = useCallback((id: string, blurColor: string) => {
setAnnotationRegions((prev) =>
prev.map((region) => (region.id === id ? { ...region, blurColor } : region)),
);
}, []);
const handleAnnotationPositionChange = useCallback(
(id: string, position: { x: number; y: number }) => {
setAnnotationRegions((prev) =>
@@ -4511,6 +4528,8 @@ export default function VideoEditor() {
onAnnotationTypeChange={handleAnnotationTypeChange}
onAnnotationStyleChange={handleAnnotationStyleChange}
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
onAnnotationBlurIntensityChange={handleAnnotationBlurIntensityChange}
onAnnotationBlurColorChange={handleAnnotationBlurColorChange}
onAnnotationDelete={handleAnnotationDelete}
selectedSpeedId={selectedSpeedId}
selectedSpeedValue={
@@ -399,7 +399,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
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,
@@ -446,6 +446,10 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
...region.figureData,
}
: undefined,
blurIntensity: isFiniteNumber(region.blurIntensity)
? clamp(region.blurIntensity, 1, 100)
: 20,
blurColor: typeof region.blurColor === "string" ? region.blurColor : undefined,
};
})
: [];
+9 -1
View File
@@ -175,7 +175,11 @@ export function trimsToClips(trims: TrimRegion[], totalDurationMs: number): Clip
return clips;
}
export type AnnotationType = "text" | "image" | "figure";
export type AnnotationType = "text" | "image" | "figure" | "blur";
export const BLUR_ANNOTATION_STRENGTH = 20;
export const BASE_PREVIEW_WIDTH = 1920;
export const BASE_PREVIEW_HEIGHT = 1080;
export type ArrowDirection =
| "up"
@@ -212,6 +216,7 @@ export interface AnnotationTextStyle {
fontStyle: "normal" | "italic";
textDecoration: "none" | "underline";
textAlign: "left" | "center" | "right";
borderRadius: number;
}
function getDefaultAnnotationFontFamily() {
@@ -243,6 +248,8 @@ export interface AnnotationRegion {
style: AnnotationTextStyle;
zIndex: number;
figureData?: FigureData;
blurIntensity?: number;
blurColor?: string;
}
export const DEFAULT_ANNOTATION_POSITION: AnnotationPosition = {
@@ -264,6 +271,7 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
fontStyle: "normal",
textDecoration: "none",
textAlign: "center",
borderRadius: 8,
};
export const DEFAULT_FIGURE_DATA: FigureData = {
+6 -1
View File
@@ -9,6 +9,7 @@
"text": "Text",
"image": "Image",
"arrow": "Arrow",
"blur": "Blur",
"textContent": "Text Content",
"textPlaceholder": "Enter your text...",
"fontStyle": "Font Style",
@@ -35,8 +36,12 @@
"tipCycleForward": "Use Tab to cycle through overlapping items.",
"tipCycleBackward": "Use Shift+Tab to cycle backwards.",
"imageUploadSuccess": "Image uploaded successfully!",
"imageUploadError": "Please upload a JPG, PNG, GIF, or WebP image file."
"imageUploadError": "Please upload a JPG, PNG, GIF, or WebP image file.",
"blurStrength": "Blur Strength: {{strength}}",
"solidColor": "Solid Color (Censorship)",
"borderRadius": "Border Radius"
},
"fontStyles": {
"classic": "Classic",
"editor": "Editor",
+6 -1
View File
@@ -9,6 +9,7 @@
"text": "Texto",
"image": "Imagen",
"arrow": "Flecha",
"blur": "Desenfoque",
"textContent": "Contenido de texto",
"textPlaceholder": "Ingresa tu texto...",
"fontStyle": "Estilo de fuente",
@@ -35,8 +36,12 @@
"tipCycleForward": "Usa Tab para recorrer los elementos superpuestos.",
"tipCycleBackward": "Usa Shift+Tab para recorrer hacia atrás.",
"imageUploadSuccess": "¡Imagen subida exitosamente!",
"imageUploadError": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP."
"imageUploadError": "Por favor sube un archivo de imagen JPG, PNG, GIF o WebP.",
"blurStrength": "Fuerza del Desenfoque: {{strength}}",
"solidColor": "Color Sólido (Censura)",
"borderRadius": "Radio del Borde"
},
"fontStyles": {
"classic": "Clásico",
"editor": "Editor",
+7 -1
View File
@@ -9,7 +9,9 @@
"text": "텍스트",
"image": "이미지",
"arrow": "화살표",
"blur": "흐리게",
"textContent": "텍스트 내용",
"textPlaceholder": "텍스트를 입력하세요...",
"fontStyle": "글꼴 스타일",
"selectStyle": "스타일 선택",
@@ -35,8 +37,12 @@
"tipCycleForward": "Tab 키로 겹치는 항목을 순환할 수 있습니다.",
"tipCycleBackward": "Shift+Tab으로 이전 항목으로 이동할 수 있습니다.",
"imageUploadSuccess": "이미지를 업로드했습니다!",
"imageUploadError": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요."
"imageUploadError": "JPG, PNG, GIF 또는 WebP 이미지 파일을 업로드해 주세요.",
"blurStrength": "블러 강도: {{strength}}",
"solidColor": "단색 (검열)",
"borderRadius": "테두리 반경"
},
"fontStyles": {
"classic": "클래식",
"editor": "에디터",
+7 -1
View File
@@ -9,7 +9,9 @@
"text": "Tekst",
"image": "Afbeelding",
"arrow": "Pijl",
"blur": "Vervagen",
"textContent": "Tekstinhoud",
"textPlaceholder": "Voer je tekst in...",
"fontStyle": "Lettertype",
"selectStyle": "Selecteer stijl",
@@ -35,8 +37,12 @@
"tipCycleForward": "Gebruik Tab om door overlappende items te bladeren.",
"tipCycleBackward": "Gebruik Shift+Tab om terug te bladeren.",
"imageUploadSuccess": "Afbeelding succesvol geüpload!",
"imageUploadError": "Upload een JPG-, PNG-, GIF- of WebP-afbeelding."
"imageUploadError": "Upload een JPG-, PNG-, GIF- of WebP-afbeelding.",
"blurStrength": "Vervagingssterkte: {{strength}}",
"solidColor": "Effen Kleur (Censuur)",
"borderRadius": "Hoekradius"
},
"fontStyles": {
"classic": "Klassiek",
"editor": "Editor",
+6 -1
View File
@@ -9,6 +9,7 @@
"text": "文本",
"image": "图片",
"arrow": "箭头",
"blur": "模糊",
"textContent": "文本内容",
"textPlaceholder": "输入文本...",
"fontStyle": "字体样式",
@@ -35,8 +36,12 @@
"tipCycleForward": "使用 Tab 循环切换重叠项目。",
"tipCycleBackward": "使用 Shift+Tab 反向循环切换。",
"imageUploadSuccess": "图片上传成功!",
"imageUploadError": "请上传 JPG、PNG、GIF 或 WebP 图片文件。"
"imageUploadError": "请上传 JPG、PNG、GIF 或 WebP 图片文件。",
"blurStrength": "模糊强度: {{strength}}",
"solidColor": "纯色 (审查)",
"borderRadius": "边框半径"
},
"fontStyles": {
"classic": "经典",
"editor": "编辑器",
+53 -1
View File
@@ -1,4 +1,5 @@
import type { AnnotationRegion, ArrowDirection } from "@/components/video-editor/types";
import { BLUR_ANNOTATION_STRENGTH, type AnnotationRegion, type ArrowDirection } from "@/components/video-editor/types";
export interface AnnotationRenderAssets {
imageCache: Map<string, HTMLImageElement>;
@@ -6,6 +7,16 @@ export interface AnnotationRenderAssets {
const annotationImagePromiseCache = new Map<string, Promise<HTMLImageElement | null>>();
let blurBufferCanvas: HTMLCanvasElement | null = null;
function getBlurBufferCanvas(): HTMLCanvasElement {
if (typeof document === "undefined") return {} as HTMLCanvasElement;
if (!blurBufferCanvas) {
blurBufferCanvas = document.createElement("canvas");
}
return blurBufferCanvas;
}
function getAnnotationImageContent(annotation: AnnotationRegion): string | null {
const source = annotation.imageContent || annotation.content;
if (!source || !source.startsWith("data:image")) {
@@ -358,6 +369,47 @@ export async function renderAnnotations(
);
}
break;
case "blur": {
const blurStrength = (annotation.blurIntensity ?? BLUR_ANNOTATION_STRENGTH) * scaleFactor;
const padding = Math.ceil(blurStrength * 2);
ctx.save();
ctx.beginPath();
const borderRadius = (annotation.style.borderRadius ?? 0) * scaleFactor;
ctx.roundRect(x, y, width, height, borderRadius);
ctx.clip();
const sx = Math.max(0, x - padding);
const sy = Math.max(0, y - padding);
const sw = Math.min(canvasWidth - sx, width + padding * 2);
const sh = Math.min(canvasHeight - sy, height + padding * 2);
if (sw > 0 && sh > 0) {
const buffer = getBlurBufferCanvas();
buffer.width = sw;
buffer.height = sh;
const bCtx = buffer.getContext("2d");
if (bCtx) {
bCtx.drawImage(ctx.canvas, sx, sy, sw, sh, 0, 0, sw, sh);
ctx.filter = `blur(${blurStrength}px)`;
ctx.drawImage(buffer, sx, sy);
if (annotation.blurColor && annotation.blurColor !== "transparent") {
ctx.filter = "none";
ctx.fillStyle = annotation.blurColor;
ctx.fillRect(x, y, width, height);
}
}
}
ctx.restore();
break;
}
}
}
}
+8 -9
View File
@@ -20,7 +20,8 @@ import type {
WebcamOverlaySettings,
ZoomTransitionEasing,
} from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
import { ZOOM_DEPTH_SCALES, BASE_PREVIEW_WIDTH, BASE_PREVIEW_HEIGHT } from "@/components/video-editor/types";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
import {
@@ -925,12 +926,11 @@ export class FrameRenderer {
this.compositeCtx
) {
// Calculate scale factor based on export vs preview dimensions
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const scaleX = this.config.width / previewWidth;
const scaleY = this.config.height / previewHeight;
const scaleX = this.config.width / BASE_PREVIEW_WIDTH;
const scaleY = this.config.height / BASE_PREVIEW_HEIGHT;
const scaleFactor = (scaleX + scaleY) / 2;
await renderAnnotations(
this.compositeCtx,
this.config.annotationRegions,
@@ -1004,12 +1004,11 @@ export class FrameRenderer {
this.videoContainer.position.set(0, 0);
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(
width / previewWidth,
height / previewHeight,
width / BASE_PREVIEW_WIDTH,
height / BASE_PREVIEW_HEIGHT,
);
const scaledBorderRadius = borderRadius * canvasScaleFactor;
this.maskGraphics.clear();