From e7dd9420e79704556a7b6d308c08cfd533b2c4b9 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 21 May 2026 00:02:12 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20layer=20effects,=20bucket?= =?UTF-8?q?=20fill,=20simple=20brush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/editor/[projectId]/page.tsx | 96 ++++- apps/web/app/page.tsx | 2 +- apps/web/components/canvas-renderer.test.tsx | 4 +- apps/web/components/canvas-renderer.tsx | 126 +++++-- .../components/editor/editor-canvas-stage.tsx | 21 +- .../editor/editor-mobile-section.test.tsx | 2 +- .../editor/editor-mobile-section.tsx | 11 +- apps/web/components/editor/editor-sidebar.tsx | 112 +++++- apps/web/components/editor/face-panel.tsx | 2 +- apps/web/components/editor/layers-panel.tsx | 141 +++++-- apps/web/hooks/use-canvas-interactions.ts | 71 +++- apps/web/hooks/use-editor-bindings.ts | 7 +- apps/web/hooks/use-editor-shortcuts.ts | 54 ++- .../web/hooks/use-face-blur-workflow.test.tsx | 43 ++- apps/web/hooks/use-face-blur-workflow.ts | 53 +-- apps/web/lib/brush-painter.ts | 99 +++++ apps/web/lib/canvas-geometry.test.ts | 4 +- apps/web/lib/canvas-geometry.ts | 2 +- apps/web/lib/editor-tool-controller.ts | 6 +- apps/web/lib/effects/face-blur.test.ts | 176 +++++++++ .../face-blur.ts} | 202 ++-------- apps/web/lib/effects/registry.ts | 46 +++ apps/web/lib/effects/types.ts | 16 + apps/web/lib/export-png.ts | 41 +- apps/web/lib/face-blur-renderer.test.ts | 355 ------------------ apps/web/lib/flood-fill.ts | 96 +++++ apps/web/lib/project-equality.test.ts | 20 +- apps/web/lib/project-equality.ts | 21 +- apps/web/lib/tools/registry.ts | 66 ++++ apps/web/lib/tools/types.ts | 10 + apps/web/messages/en.json | 7 +- apps/web/messages/ja.json | 7 +- apps/web/messages/th.json | 7 +- apps/web/store/editor-store.test.ts | 6 +- apps/web/store/editor-store.ts | 178 +++++---- packages/editor-core/src/effects/face-blur.ts | 23 ++ packages/editor-core/src/effects/registry.ts | 23 ++ packages/editor-core/src/index.test.ts | 12 +- packages/editor-core/src/index.ts | 224 ++--------- packages/editor-core/src/layers.ts | 114 ++++++ packages/editor-core/src/normalize.ts | 22 ++ packages/editor-core/src/project.ts | 38 ++ packages/editor-core/src/serialization.ts | 26 ++ packages/editor-core/src/tools/registry.ts | 65 ++++ packages/storage/src/index.test.ts | 38 +- packages/storage/src/index.ts | 2 +- packages/types/src/index.ts | 16 +- 47 files changed, 1675 insertions(+), 1038 deletions(-) create mode 100644 apps/web/lib/brush-painter.ts create mode 100644 apps/web/lib/effects/face-blur.test.ts rename apps/web/lib/{face-blur-renderer.ts => effects/face-blur.ts} (51%) create mode 100644 apps/web/lib/effects/registry.ts create mode 100644 apps/web/lib/effects/types.ts delete mode 100644 apps/web/lib/face-blur-renderer.test.ts create mode 100644 apps/web/lib/flood-fill.ts create mode 100644 apps/web/lib/tools/registry.ts create mode 100644 apps/web/lib/tools/types.ts create mode 100644 packages/editor-core/src/effects/face-blur.ts create mode 100644 packages/editor-core/src/effects/registry.ts create mode 100644 packages/editor-core/src/layers.ts create mode 100644 packages/editor-core/src/normalize.ts create mode 100644 packages/editor-core/src/project.ts create mode 100644 packages/editor-core/src/serialization.ts create mode 100644 packages/editor-core/src/tools/registry.ts diff --git a/apps/web/app/editor/[projectId]/page.tsx b/apps/web/app/editor/[projectId]/page.tsx index 632f580..1b5f573 100644 --- a/apps/web/app/editor/[projectId]/page.tsx +++ b/apps/web/app/editor/[projectId]/page.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useParams } from "next/navigation"; -import { MousePointer2, Hand, ScanFace, Type, ImagePlus } from "lucide-react"; +import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react"; import { useEditorStore } from "../../../store/editor-store"; import { useUiStore, isDarkTheme } from "../../../store/ui-store"; import { CanvasSizeModal } from "../../../components/canvas-size-modal"; @@ -12,6 +12,8 @@ import { EditorMobileSection } from "../../../components/editor/editor-mobile-se import { EditorSidebar } from "../../../components/editor/editor-sidebar"; import { ToolRail } from "../../../components/editor/tool-rail"; import { exportProjectAsPng } from "../../../lib/export-png"; +import { floodFillDataUrl } from "../../../lib/flood-fill"; +import { commitStroke, type BrushStroke } from "../../../lib/brush-painter"; import { createEditorToolControllers } from "../../../lib/editor-tool-controller"; import { useFaceDetection } from "../../../hooks/use-face-detection"; import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow"; @@ -29,6 +31,8 @@ const TOOL_ICONS: Record> = pointer: MousePointer2, hand: Hand, face: ScanFace, + fill: PaintBucket, + brush: Brush, "add-text": Type, "import-image": ImagePlus, }; @@ -64,8 +68,13 @@ export default function EditorPage() { removeSelectedLayer, addLayerByType, moveSelectedLayerOrder, + addCanvasSizedLayer, importImageFromFile, - setImageLayerFaceBlur, + updateImageLayerSource, + setLayerEffect, + removeLayerEffect, + setLayerVisible, + setEffectEnabled, setCanvasSize, setTool, undo, @@ -83,11 +92,17 @@ export default function EditorPage() { const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu(); const [canvasModalOpen, setCanvasModalOpen] = React.useState(false); const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false); + const [fillColor, setFillColor] = React.useState("#ff0000"); + const [fillTolerance, setFillTolerance] = React.useState(32); + const [brushColor, setBrushColor] = React.useState("#000000"); + const [brushSize, setBrushSize] = React.useState(20); + const [brushOpacity, setBrushOpacity] = React.useState(1); + const [brushHardness, setBrushHardness] = React.useState(0.8); const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle"); const imageInputRef = React.useRef(null); const selectedImageLayer = React.useMemo(() => { - if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) { + if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) { return null; } return { @@ -126,7 +141,8 @@ export default function EditorPage() { selectedLayer, faceDetectionsLayerId, faceDetections, - setImageLayerFaceBlur, + setLayerEffect, + removeLayerEffect, }); React.useEffect(() => { @@ -174,6 +190,8 @@ export default function EditorPage() { useEditorShortcuts({ onSave: handleSave, + onUndo: undo, + onRedo: redo, onCopy: copySelectedLayer, onCut: cutSelectedLayer, onPaste: pasteLayer, @@ -204,6 +222,55 @@ export default function EditorPage() { setCanvasSize(width, height); } + const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => { + const layer = project.layers.find((l) => l.id === layerId); + if (!layer || layer.type !== "raster" || !layer.sourceUri) return; + const layerWidth = layer.width ?? Math.round(200 * layer.scale); + const layerHeight = layer.height ?? Math.round(150 * layer.scale); + // x/y are in layer CSS-pixel space; scale to image pixel space + const img = new Image(); + const uri = layer.sourceUri; + const color = fillColor; + const tolerance = fillTolerance; + img.onload = () => { + const scaleX = img.naturalWidth / layerWidth; + const scaleY = img.naturalHeight / layerHeight; + const pixelX = x * scaleX; + const pixelY = y * scaleY; + floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => { + if (nextUri !== uri) updateImageLayerSource(layerId, nextUri); + }).catch(() => {}); + }; + img.src = uri; + }, [project.layers, fillColor, fillTolerance, updateImageLayerSource]); + + const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => { + const layer = project.layers.find((l) => l.id === layerId); + if (!layer || layer.type !== "raster" || !layer.sourceUri) return; + try { + const nextUri = await commitStroke(layer.sourceUri, stroke); + updateImageLayerSource(layerId, nextUri); + } catch {} + }, [project.layers, updateImageLayerSource]); + + const handleCreateFillLayer = React.useCallback(() => { + const canvas = document.createElement("canvas"); + canvas.width = cw; + canvas.height = ch; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.fillStyle = fillColor; + ctx.fillRect(0, 0, cw, ch); + addCanvasSizedLayer(canvas.toDataURL("image/png"), "Fill Layer"); + }, [cw, ch, fillColor, addCanvasSizedLayer]); + + const handleAddLayer = React.useCallback(() => { + const canvas = document.createElement("canvas"); + canvas.width = cw; + canvas.height = ch; + addCanvasSizedLayer(canvas.toDataURL("image/png"), "Layer"); + }, [cw, ch, addCanvasSizedLayer]); + const toolControllers = React.useMemo( () => createEditorToolControllers({ @@ -213,6 +280,8 @@ export default function EditorPage() { pointer: t("editor.toolPointer"), pan: t("editor.toolPan"), face: t("editor.toolFace"), + fill: t("editor.toolFill"), + brush: t("editor.toolBrush"), text: t("editor.toolText"), image: t("editor.toolImage"), }, @@ -295,11 +364,28 @@ export default function EditorPage() { onCopy={copySelectedLayer} onCut={cutSelectedLayer} onPaste={pasteLayer} + onFillLayer={handleFillLayer} + brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }} + onBrushCommit={handleBrushCommit} /> setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)} onMoveLayerOrder={moveSelectedLayerOrder} onRemoveSelectedLayer={removeSelectedLayer} onUndo={undo} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index bbcaf3a..fdc9bb6 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -92,7 +92,7 @@ export default function HomePage() { const base = createProject(title, "free"); const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height); - const project = addLayer(projectWithImageCanvas, createLayer("image", { + const project = addLayer(projectWithImageCanvas, createLayer("raster", { name: file.name, sourceUri, x: 0, diff --git a/apps/web/components/canvas-renderer.test.tsx b/apps/web/components/canvas-renderer.test.tsx index 667d987..6119d58 100644 --- a/apps/web/components/canvas-renderer.test.tsx +++ b/apps/web/components/canvas-renderer.test.tsx @@ -26,7 +26,7 @@ describe("CanvasRenderer", () => { it("keeps the rotation handle interactive", () => { const layer: Layer = { id: "layer-1", - type: "image", + type: "raster", sourceUri: "data:image/png;base64,test", x: 0, y: 0, @@ -35,6 +35,8 @@ describe("CanvasRenderer", () => { scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, }; const onRotateLayer = vi.fn(); diff --git a/apps/web/components/canvas-renderer.tsx b/apps/web/components/canvas-renderer.tsx index 3debe5c..ec926ac 100644 --- a/apps/web/components/canvas-renderer.tsx +++ b/apps/web/components/canvas-renderer.tsx @@ -8,10 +8,13 @@ import { CANVAS_ROTATE_HANDLE_BASE_SIZE, } from "../lib/editor-constants"; import { buildFaceLabelOverlays } from "../lib/canvas-geometry"; -import { renderImageWithFaceBlur } from "../lib/face-blur-renderer"; +import { renderLayerWithEffects } from "../lib/effects/registry"; +import { getToolUiDefinition } from "../lib/tools/registry"; +import { getToolDefinition } from "@pien-studio/editor-core"; import { useCanvasInteractions } from "../hooks/use-canvas-interactions"; import { useTranslations } from "../hooks/use-translations"; -import type { FaceBlurMethod, Layer } from "@pien-studio/types"; +import { createStroke, paintSegment, type BrushStroke, type BrushOptions } from "../lib/brush-painter"; +import type { Layer, LayerEffect } from "@pien-studio/types"; interface CanvasRendererProps { layers: Layer[]; @@ -29,7 +32,10 @@ interface CanvasRendererProps { onInteractionEnd?: () => void; onContextMenu?: (x: number, y: number) => void; isDark: boolean; - tool?: "pointer" | "hand" | "face"; + tool?: string; + onFillLayer?: (layerId: string, x: number, y: number) => void; + brushOptions?: BrushOptions; + onBrushCommit?: (layerId: string, stroke: BrushStroke) => void; faceDetections?: { x: number; y: number; @@ -40,49 +46,57 @@ interface CanvasRendererProps { faceOverlayLayerId?: string | null; faceBlurPreview?: { layerId: string; - method: FaceBlurMethod; - amount: number; - regions: { x: number; y: number; width: number; height: number }[]; + effects: LayerEffect[]; } | null; } -function BlurredImageLayer({ +function BrushOverlayCanvas({ stroke, width, height }: { stroke: HTMLCanvasElement; width: number; height: number }) { + const canvasRef = React.useRef(null); + + React.useEffect(() => { + const el = canvasRef.current; + if (!el) return; + const ctx = el.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, el.width, el.height); + ctx.drawImage(stroke, 0, 0, el.width, el.height); + }); + + return ( + + ); +} + +function EffectImageLayer({ layer, width, height, - faceBlurOverride, + effectsOverride, }: { layer: Layer; width: number; height: number; - faceBlurOverride?: { - method: FaceBlurMethod; - amount: number; - regions: { - x: number; - y: number; - width: number; - height: number; - censorColor?: string; - }[]; - censorColor?: string; - } | null; + effectsOverride?: LayerEffect[] | null; }) { const canvasRef = React.useRef(null); const imageRef = React.useRef(null); + const activeEffects = effectsOverride ?? layer.effects; + const draw = React.useCallback(() => { const canvas = canvasRef.current; const image = imageRef.current; if (!canvas || !image) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - const cw = canvas.width; - const ch = canvas.height; - ctx.clearRect(0, 0, cw, ch); - const blur = faceBlurOverride ?? layer.faceBlur; - renderImageWithFaceBlur(ctx, image, blur, cw, ch); - }, [faceBlurOverride, layer.faceBlur]); + ctx.clearRect(0, 0, canvas.width, canvas.height); + renderLayerWithEffects(ctx, image, activeEffects, canvas.width, canvas.height); + }, [activeEffects]); React.useEffect(() => { if (!layer.sourceUri) return; @@ -129,6 +143,9 @@ export function CanvasRenderer({ onInteractionStart, onInteractionEnd, onContextMenu, + onFillLayer, + brushOptions, + onBrushCommit, isDark, tool = "pointer", faceDetections = [], @@ -136,6 +153,36 @@ export function CanvasRenderer({ faceBlurPreview = null, }: CanvasRendererProps) { const { t } = useTranslations(); + + const brushStrokeRef = React.useRef(null); + const brushLastPosRef = React.useRef<{ x: number; y: number } | null>(null); + const [brushOverlay, setBrushOverlay] = React.useState<{ layerId: string; canvas: HTMLCanvasElement } | null>(null); + + const handleBrushStrokeStart = React.useCallback((layerId: string, x: number, y: number, layerWidth: number, layerHeight: number): void => { + if (!brushOptions) return; + const stroke = createStroke(layerWidth, layerHeight); + brushStrokeRef.current = stroke; + brushLastPosRef.current = { x, y }; + paintSegment(stroke, x, y, x, y, brushOptions); + setBrushOverlay({ layerId, canvas: stroke.canvas }); + }, [brushOptions]); + + const handleBrushStrokeMove = React.useCallback((_layerId: string, x: number, y: number) => { + if (!brushStrokeRef.current || !brushLastPosRef.current || !brushOptions) return; + const { x: lx, y: ly } = brushLastPosRef.current; + paintSegment(brushStrokeRef.current, lx, ly, x, y, brushOptions); + brushLastPosRef.current = { x, y }; + setBrushOverlay((prev) => prev ? { ...prev } : prev); + }, [brushOptions]); + + const handleBrushStrokeEnd = React.useCallback((layerId: string) => { + const stroke = brushStrokeRef.current; + brushStrokeRef.current = null; + brushLastPosRef.current = null; + setBrushOverlay(null); + if (stroke && onBrushCommit) onBrushCommit(layerId, stroke); + }, [onBrushCommit]); + const { containerRef, viewport, @@ -164,6 +211,10 @@ export function CanvasRenderer({ onInteractionStart, onInteractionEnd, onContextMenu, + onFillLayer, + onBrushStrokeStart: handleBrushStrokeStart, + onBrushStrokeMove: handleBrushStrokeMove, + onBrushStrokeEnd: handleBrushStrokeEnd, }); const faceLabelOverlays = React.useMemo(() => { @@ -194,10 +245,10 @@ export function CanvasRenderer({ height: "100%", touchAction: "none", userSelect: "none", - cursor: tool === "hand" || isSpacePan ? "grab" : "default", + cursor: isSpacePan ? "grab" : (getToolUiDefinition(tool)?.cursor ?? "default"), }} onPointerDown={(e) => { - if (tool === "pointer" && e.button === 0) onSelectLayer(null); + if (getToolDefinition(tool)?.interactionMode === "select" && e.button === 0) onSelectLayer(null); onContainerPointerDown(e); }} onMouseDown={(e) => e.preventDefault()} @@ -232,7 +283,7 @@ export function CanvasRenderer({ > {layers.map((layer, idx) => { const isSelected = layer.id === selectedLayerId; - const isImage = layer.type === "image"; + const isImage = layer.type === "raster"; const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined); @@ -256,7 +307,8 @@ export function CanvasRenderer({ height: layerHeight, transform: `rotate(${layer.rotation}deg)`, opacity: layer.opacity, - cursor: "move", + display: layer.visible === false ? "none" : undefined, + cursor: getToolUiDefinition(tool)?.cursor ?? "move", border: isSelected ? "2px solid var(--color-accent-strong)" : "1px dashed transparent", @@ -270,17 +322,14 @@ export function CanvasRenderer({ onClick={() => onSelectLayer(layer.id)} > {isImage && layer.sourceUri ? ( - (faceBlurPreview && - faceBlurPreview.layerId === layer.id && - faceBlurPreview.regions.length > 0) || - (layer.faceBlur && layer.faceBlur.regions.length > 0) ? ( - 0 || (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? ( + @@ -308,6 +357,9 @@ export function CanvasRenderer({ {layer.type} )} + {brushOverlay && brushOverlay.layerId === layer.id ? ( + + ) : null} {tool === "face" && faceOverlayLayerId === layer.id ? faceDetections.map((face, index) => (
void; + brushOptions?: BrushOptions; + onBrushCommit?: (layerId: string, stroke: BrushStroke) => void; faceDetections: FaceDetectionOverlay[]; faceOverlayLayerId: string | null; - faceBlurPreview: { - layerId: string; - method: FaceBlurMethod; - amount: number; - regions: { x: number; y: number; width: number; height: number }[]; - } | null; + faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null; contextMenu: { x: number; y: number } | null; labels: { copy: string; cut: string; paste: string }; onSelectLayer: (id: string | null) => void; @@ -64,6 +63,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) { onCopy, onCut, onPaste, + onFillLayer, + brushOptions, + onBrushCommit, } = props; return ( @@ -93,6 +95,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) { onInteractionStart={onInteractionStart} onInteractionEnd={onInteractionEnd} onContextMenu={onContextMenu} + onFillLayer={onFillLayer} + brushOptions={brushOptions} + onBrushCommit={onBrushCommit} isDark={isDark} tool={tool} faceDetections={faceDetections} diff --git a/apps/web/components/editor/editor-mobile-section.test.tsx b/apps/web/components/editor/editor-mobile-section.test.tsx index 22ded56..080b302 100644 --- a/apps/web/components/editor/editor-mobile-section.test.tsx +++ b/apps/web/components/editor/editor-mobile-section.test.tsx @@ -17,7 +17,7 @@ describe("EditorMobileSection", () => { layers: [], selectedLayerId: null, tool: "face", - faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f" }], + faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f", sourceWidth: 100, sourceHeight: 100 }], faceOverlayLayerId: null, faceStatus: "idle", faceBlurPreview: null, diff --git a/apps/web/components/editor/editor-mobile-section.tsx b/apps/web/components/editor/editor-mobile-section.tsx index 1c44569..1530daa 100644 --- a/apps/web/components/editor/editor-mobile-section.tsx +++ b/apps/web/components/editor/editor-mobile-section.tsx @@ -1,6 +1,6 @@ import React from "react"; import { CanvasRenderer } from "../canvas-renderer"; -import type { FaceBlurMethod, Layer } from "@pien-studio/types"; +import type { Layer, LayerEffect } from "@pien-studio/types"; import type { FaceDetectionOverlay } from "../../hooks/use-face-detection"; type EditorMobileSectionProps = { @@ -9,16 +9,11 @@ type EditorMobileSectionProps = { canvasHeight: number; layers: Layer[]; selectedLayerId: string | null; - tool: "pointer" | "hand" | "face"; + tool: string; faceDetections: FaceDetectionOverlay[]; faceOverlayLayerId: string | null; faceStatus: "idle" | "detecting" | "unsupported"; - faceBlurPreview: { - layerId: string; - method: FaceBlurMethod; - amount: number; - regions: { x: number; y: number; width: number; height: number }[]; - } | null; + faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null; labels: { resize: string; faceMlFailedShort: string; diff --git a/apps/web/components/editor/editor-sidebar.tsx b/apps/web/components/editor/editor-sidebar.tsx index 065fd8c..e459262 100644 --- a/apps/web/components/editor/editor-sidebar.tsx +++ b/apps/web/components/editor/editor-sidebar.tsx @@ -7,7 +7,21 @@ import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-det type EditorSidebarProps = { isDark: boolean; - tool: "pointer" | "hand" | "face"; + tool: string; + fillColor?: string; + fillTolerance?: number; + onSetFillColor?: (color: string) => void; + onSetFillTolerance?: (tolerance: number) => void; + onCreateFillLayer?: () => void; + brushColor?: string; + brushSize?: number; + brushOpacity?: number; + brushHardness?: number; + onSetBrushColor?: (color: string) => void; + onSetBrushSize?: (size: number) => void; + onSetBrushOpacity?: (opacity: number) => void; + onSetBrushHardness?: (hardness: number) => void; + onAddLayer?: () => void; layers: Layer[]; selectedLayerId: string | null; selectedLayer: Layer | null; @@ -22,6 +36,8 @@ type EditorSidebarProps = { censorColor: string; selectedFaceIndices: number[]; onSelectLayer: (layerId: string | null) => void; + onSetLayerVisible: (layerId: string, visible: boolean) => void; + onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union onMoveLayerOrder: (direction: "up" | "down") => void; onRemoveSelectedLayer: () => void; onUndo: () => void; @@ -39,6 +55,20 @@ type EditorSidebarProps = { export function EditorSidebar({ isDark, tool, + fillColor, + fillTolerance, + onSetFillColor, + onSetFillTolerance, + onCreateFillLayer, + brushColor, + brushSize, + brushOpacity, + brushHardness, + onSetBrushColor, + onSetBrushSize, + onSetBrushOpacity, + onSetBrushHardness, + onAddLayer, layers, selectedLayerId, selectedLayer, @@ -53,6 +83,8 @@ export function EditorSidebar({ censorColor, selectedFaceIndices, onSelectLayer, + onSetLayerVisible, + onSetEffectEnabled, onMoveLayerOrder, onRemoveSelectedLayer, onUndo, @@ -74,10 +106,86 @@ export function EditorSidebar({ selectedLayerId={selectedLayerId} isDark={isDark} onSelectLayer={(layerId) => onSelectLayer(layerId)} + onSetLayerVisible={onSetLayerVisible} + onSetEffectEnabled={onSetEffectEnabled} onMoveLayerOrder={onMoveLayerOrder} onRemoveSelectedLayer={onRemoveSelectedLayer} + onAddLayer={onAddLayer} /> + {tool === "fill" && onSetFillColor && onSetFillTolerance ? ( +
+

+ Fill +

+
+ + onSetFillColor(e.target.value)} + className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5" + /> + {fillColor ?? "#ff0000"} +
+
+
+ + {fillTolerance ?? 32} +
+ onSetFillTolerance(Number(e.target.value))} + className="w-full accent-[var(--color-accent-strong)]" + /> +
+
+ ) : null} + + {tool === "brush" && onSetBrushColor && onSetBrushSize && onSetBrushOpacity && onSetBrushHardness ? ( +
+

+ Brush +

+
+ + onSetBrushColor(e.target.value)} + className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5" + /> + {brushColor ?? "#000000"} +
+
+
+
+ + {brushSize ?? 20}px +
+ onSetBrushSize(Number(e.target.value))} className="w-full accent-[var(--color-accent-strong)]" /> +
+
+
+ + {Math.round((brushOpacity ?? 1) * 100)}% +
+ onSetBrushOpacity(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" /> +
+
+
+ + {Math.round((brushHardness ?? 0.8) * 100)}% +
+ onSetBrushHardness(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" /> +
+
+
+ ) : null} + {tool === "face" ? ( e.kind === "face-blur"))} onSetBlurMethod={onSetBlurMethod} onSetBlurAmount={onSetBlurAmount} onSetCensorColor={onSetCensorColor} diff --git a/apps/web/components/editor/face-panel.tsx b/apps/web/components/editor/face-panel.tsx index 0f3f58d..a9a8c70 100644 --- a/apps/web/components/editor/face-panel.tsx +++ b/apps/web/components/editor/face-panel.tsx @@ -57,7 +57,7 @@ export function FacePanel({ ? t("editor.faceMlFailed") : faceStatus === "detecting" ? t("editor.detectingFaces") - : selectedLayer?.type !== "image" + : selectedLayer?.type !== "raster" ? t("editor.selectImageLayer") : faceDetections.length === 0 ? t("editor.noFacesFound") diff --git a/apps/web/components/editor/layers-panel.tsx b/apps/web/components/editor/layers-panel.tsx index 151b5c5..e1ef0af 100644 --- a/apps/web/components/editor/layers-panel.tsx +++ b/apps/web/components/editor/layers-panel.tsx @@ -2,69 +2,146 @@ import type { Layer } from "@pien-studio/types"; import Image from "next/image"; +import { Eye, EyeOff } from "lucide-react"; import { useTranslations } from "../../hooks/use-translations"; import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme"; +const EFFECT_LABELS: Record = { + "face-blur": "Face Blur", +}; + type Props = { layers: Layer[]; selectedLayerId: string | null; isDark: boolean; onSelectLayer: (layerId: string) => void; + onSetLayerVisible: (layerId: string, visible: boolean) => void; + onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; onMoveLayerOrder: (direction: "up" | "down") => void; onRemoveSelectedLayer: () => void; + onAddLayer?: () => void; }; -export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onMoveLayerOrder, onRemoveSelectedLayer }: Props) { +export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onSetLayerVisible, onSetEffectEnabled, onMoveLayerOrder, onRemoveSelectedLayer, onAddLayer }: Props) { const { t } = useTranslations(); return (

{t("editor.layers")}

- - {layers.length} - +
+ {onAddLayer ? ( + + ) : null} + + {layers.length} + +
-
+
{layers.slice().reverse().map((layer, idx) => { const isSelected = layer.id === selectedLayerId; - const isImageLayer = layer.type === "image" || layer.type === "sticker"; + const isRaster = layer.type === "raster" || layer.type === "sticker"; + const hasEffects = layer.effects.length > 0; + const isHidden = layer.visible === false; return ( - + + {/* Info */} + + + {/* Visibility toggle */} + + + {/* Layer index */} + + {layers.length - idx} +
- - + + {/* Effects chips */} + {hasEffects ? ( +
+ {layer.effects.map((effect) => { + const isDisabled = effect.enabled === false; + return ( + + ); + })} +
+ ) : null} +
); })} - {layers.length === 0 ?
{t("editor.noLayersYet")}
: null} + {layers.length === 0 ? ( +
+ {t("editor.noLayersYet")} +
+ ) : null}
-
+
diff --git a/apps/web/hooks/use-canvas-interactions.ts b/apps/web/hooks/use-canvas-interactions.ts index cdd2a2c..5fb7b03 100644 --- a/apps/web/hooks/use-canvas-interactions.ts +++ b/apps/web/hooks/use-canvas-interactions.ts @@ -1,5 +1,6 @@ import React from "react"; import type { Layer } from "@pien-studio/types"; +import { getToolDefinition } from "@pien-studio/editor-core"; type Viewport = { x: number; @@ -10,7 +11,7 @@ type Viewport = { type UseCanvasInteractionsOptions = { canvasWidth: number; canvasHeight: number; - tool: "pointer" | "hand" | "face"; + tool: string; onSelectLayer: (id: string | null) => void; onMoveLayer: (id: string, x: number, y: number) => void; onMoveLayerEnd?: (id: string, x: number, y: number) => void; @@ -21,6 +22,10 @@ type UseCanvasInteractionsOptions = { onInteractionStart?: () => void; onInteractionEnd?: () => void; onContextMenu?: (x: number, y: number) => void; + onFillLayer?: (layerId: string, x: number, y: number) => void; + onBrushStrokeStart?: (layerId: string, x: number, y: number, layerWidth: number, layerHeight: number) => void; + onBrushStrokeMove?: (layerId: string, x: number, y: number) => void; + onBrushStrokeEnd?: (layerId: string) => void; }; const MIN_SCALE = 0.1; @@ -43,6 +48,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { onInteractionStart, onInteractionEnd, onContextMenu, + onFillLayer, + onBrushStrokeStart, + onBrushStrokeMove, + onBrushStrokeEnd, } = options; const [viewport, setViewport] = React.useState({ x: 0, y: 0, scale: 1 }); const containerRef = React.useRef(null); @@ -86,6 +95,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { startRotation: number; lastRotation: number; } | null>(null); + const brushRef = React.useRef<{ id: string; lastX: number; lastY: number; rect: DOMRect } | null>(null); + const pinchRef = React.useRef<{ active: boolean; initialPinchPx: number; @@ -237,10 +248,18 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { return; } if (e.button !== 0) return; - if (tool !== "hand" && tool !== "face" && !isSpacePan) return; - e.currentTarget.setPointerCapture(e.pointerId); - isPanning.current = true; - lastPos.current = { x: e.clientX, y: e.clientY }; + const toolDef = getToolDefinition(tool); + if (toolDef?.interactionMode !== "select" && !isSpacePan) { + e.currentTarget.setPointerCapture(e.pointerId); + isPanning.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + return; + } + if (isSpacePan) { + e.currentTarget.setPointerCapture(e.pointerId); + isPanning.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + } } function onContainerPointerMove(e: React.PointerEvent) { @@ -251,7 +270,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy })); return; } - if (tool === "pointer" && rotateRef.current && onRotateLayer) { + if (brushRef.current && onBrushStrokeMove) { + const { rect } = brushRef.current; + const localX = (e.clientX - rect.left) / viewport.scale; + const localY = (e.clientY - rect.top) / viewport.scale; + brushRef.current.lastX = localX; + brushRef.current.lastY = localY; + onBrushStrokeMove(brushRef.current.id, localX, localY); + return; + } + const toolDef = getToolDefinition(tool); + if (toolDef?.allowsLayerRotate && rotateRef.current && onRotateLayer) { const { centerX, centerY, startAngle, startRotation, id } = rotateRef.current; const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX); const delta = currentAngle - startAngle; @@ -260,7 +289,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { onRotateLayer(id, nextRotation); return; } - if (tool === "pointer" && resizeRef.current && onResizeLayer) { + if (toolDef?.allowsLayerResize && resizeRef.current && onResizeLayer) { const dx = (e.clientX - resizeRef.current.startX) / viewport.scale; const dy = (e.clientY - resizeRef.current.startY) / viewport.scale; const corner = resizeRef.current.corner; @@ -315,7 +344,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { }); return; } - if (tool === "pointer" && dragRef.current) { + if (toolDef?.allowsLayerDrag && dragRef.current) { const dx = (e.clientX - dragRef.current.startEventX) / viewport.scale; const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale; const nextX = dragRef.current.startLayerX + dx; @@ -362,6 +391,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { const { id, lastRotation } = rotateRef.current; if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation); } + if (brushRef.current && onBrushStrokeEnd) { + onBrushStrokeEnd(brushRef.current.id); + brushRef.current = null; + } dragRef.current = null; resizeRef.current = null; resizeMovePendingRef.current = null; @@ -372,6 +405,24 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { function onLayerPointerDown(e: React.PointerEvent, layer: Layer) { if (isSpacePan) return; e.stopPropagation(); + const toolDef = getToolDefinition(tool); + if (toolDef?.interactionMode === "paint") { + const rect = e.currentTarget.getBoundingClientRect(); + const localX = (e.clientX - rect.left) / viewport.scale; + const localY = (e.clientY - rect.top) / viewport.scale; + onSelectLayer(layer.id); + if (tool === "brush" && onBrushStrokeStart) { + e.currentTarget.setPointerCapture(e.pointerId); + brushRef.current = { id: layer.id, lastX: localX, lastY: localY, rect: e.currentTarget.getBoundingClientRect() }; + const lw = layer.width ?? Math.round(200 * layer.scale); + const lh = layer.height ?? Math.round(150 * layer.scale); + onBrushStrokeStart(layer.id, localX, localY, lw, lh); + beginInteraction(); + } else if (onFillLayer) { + onFillLayer(layer.id, localX, localY); + } + return; + } e.currentTarget.setPointerCapture(e.pointerId); dragRef.current = { id: layer.id, @@ -387,7 +438,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { } function onResizeHandleDown(e: React.PointerEvent, layer: Layer, corner: string) { - if (tool !== "pointer" || !onResizeLayer) return; + if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return; e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); rotateRef.current = null; @@ -411,7 +462,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { } function onRotateHandleDown(e: React.PointerEvent, layer: Layer) { - if (tool !== "pointer" || !onRotateLayer) return; + if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return; e.stopPropagation(); e.currentTarget.setPointerCapture(e.pointerId); resizeRef.current = null; diff --git a/apps/web/hooks/use-editor-bindings.ts b/apps/web/hooks/use-editor-bindings.ts index 5f45e30..1c0a49b 100644 --- a/apps/web/hooks/use-editor-bindings.ts +++ b/apps/web/hooks/use-editor-bindings.ts @@ -32,8 +32,13 @@ export function useEditorBindings() { removeSelectedLayer: s.removeSelectedLayer, addLayerByType: s.addLayerByType, moveSelectedLayerOrder: s.moveSelectedLayerOrder, + addCanvasSizedLayer: s.addCanvasSizedLayer, importImageFromFile: s.importImageFromFile, - setImageLayerFaceBlur: s.setImageLayerFaceBlur, + updateImageLayerSource: s.updateImageLayerSource, + setLayerEffect: s.setLayerEffect, + removeLayerEffect: s.removeLayerEffect, + setLayerVisible: s.setLayerVisible, + setEffectEnabled: s.setEffectEnabled, setCanvasSize: s.setCanvasSize, setTool: s.setTool, undo: s.undo, diff --git a/apps/web/hooks/use-editor-shortcuts.ts b/apps/web/hooks/use-editor-shortcuts.ts index 246cf75..c9d54f1 100644 --- a/apps/web/hooks/use-editor-shortcuts.ts +++ b/apps/web/hooks/use-editor-shortcuts.ts @@ -2,14 +2,20 @@ import React from "react"; type UseEditorShortcutsOptions = { onSave: () => void; + onUndo: () => void; + onRedo: () => void; onCopy: () => void; onCut: () => void; - onPaste: () => void; + onPaste: (e?: ClipboardEvent) => void; onDelete: () => void; }; +function isEditableTarget(e: Event) { + return e.target instanceof HTMLElement && /^(input|textarea|select)$/i.test(e.target.tagName); +} + export function useEditorShortcuts(options: UseEditorShortcutsOptions) { - const { onSave, onCopy, onCut, onPaste, onDelete } = options; + const { onSave, onUndo, onRedo, onCopy, onCut, onPaste, onDelete } = options; React.useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -19,31 +25,57 @@ export function useEditorShortcuts(options: UseEditorShortcutsOptions) { onSave(); return; } - if (e.key.toLowerCase() === "c") { + if (e.key.toLowerCase() === "z" && !e.shiftKey) { e.preventDefault(); - onCopy(); + onUndo(); return; } - if (e.key.toLowerCase() === "x") { + if (e.key.toLowerCase() === "z" && e.shiftKey) { e.preventDefault(); - onCut(); + onRedo(); return; } - if (e.key.toLowerCase() === "v") { + if (e.key.toLowerCase() === "y") { e.preventDefault(); - onPaste(); + onRedo(); return; } } if (e.key === "Delete" || e.key === "Backspace") { - if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return; + if (isEditableTarget(e)) return; e.preventDefault(); onDelete(); } } + function handleCopy(e: ClipboardEvent) { + if (isEditableTarget(e)) return; + e.preventDefault(); + onCopy(); + } + + function handleCut(e: ClipboardEvent) { + if (isEditableTarget(e)) return; + e.preventDefault(); + onCut(); + } + + function handlePaste(e: ClipboardEvent) { + if (isEditableTarget(e)) return; + e.preventDefault(); + onPaste(e); + } + window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [onCopy, onCut, onDelete, onPaste, onSave]); + window.addEventListener("copy", handleCopy); + window.addEventListener("cut", handleCut); + window.addEventListener("paste", handlePaste); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("copy", handleCopy); + window.removeEventListener("cut", handleCut); + window.removeEventListener("paste", handlePaste); + }; + }, [onCopy, onCut, onDelete, onPaste, onRedo, onSave, onUndo]); } diff --git a/apps/web/hooks/use-face-blur-workflow.test.tsx b/apps/web/hooks/use-face-blur-workflow.test.tsx index 36bb47e..f335964 100644 --- a/apps/web/hooks/use-face-blur-workflow.test.tsx +++ b/apps/web/hooks/use-face-blur-workflow.test.tsx @@ -1,25 +1,28 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import type { Layer } from "@pien-studio/types"; +import type { Layer, LayerEffect } from "@pien-studio/types"; import { useFaceBlurWorkflow } from "./use-face-blur-workflow"; function makeImageLayer(overrides: Partial = {}): Layer { return { id: "layer-1", - type: "image", + type: "raster", sourceUri: "data:image/png;base64,abc", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, ...overrides, }; } describe("useFaceBlurWorkflow", () => { it("selects all detected faces by default when no existing face blur", async () => { - const setImageLayerFaceBlur = vi.fn(); + const setLayerEffect = vi.fn(); + const removeLayerEffect = vi.fn(); const selectedLayer = makeImageLayer(); const faceDetections = [ { x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }, @@ -31,28 +34,37 @@ describe("useFaceBlurWorkflow", () => { selectedLayer, faceDetectionsLayerId: "layer-1", faceDetections, - setImageLayerFaceBlur, + setLayerEffect, + removeLayerEffect, }), ); await waitFor(() => { expect(result.current.selectedFaceIndices).toEqual([0, 1]); - expect(result.current.faceBlurPreview?.regions).toHaveLength(2); + const faceBlurEffect = result.current.faceBlurPreview?.effects.find((e) => e.kind === "face-blur"); + expect(faceBlurEffect?.kind === "face-blur" ? faceBlurEffect.regions : undefined).toHaveLength(2); }); }); it("keeps selection empty when selected image already has blur", async () => { - const setImageLayerFaceBlur = vi.fn(); - const selectedLayer = makeImageLayer({ - faceBlur: { method: "gaussian", amount: 14, regions: [{ x: 0, y: 0, width: 4, height: 4 }] }, - }); + const setLayerEffect = vi.fn(); + const removeLayerEffect = vi.fn(); + const faceBlurEffect: LayerEffect = { + kind: "face-blur", + enabled: true, + method: "gaussian", + amount: 14, + regions: [{ x: 0, y: 0, width: 4, height: 4 }], + }; + const selectedLayer = makeImageLayer({ effects: [faceBlurEffect] }); const { result } = renderHook(() => useFaceBlurWorkflow({ selectedLayer, faceDetectionsLayerId: "layer-1", faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }], - setImageLayerFaceBlur, + setLayerEffect, + removeLayerEffect, }), ); @@ -63,7 +75,8 @@ describe("useFaceBlurWorkflow", () => { }); it("toggles face selection and applies blur to selected regions", async () => { - const setImageLayerFaceBlur = vi.fn(); + const setLayerEffect = vi.fn(); + const removeLayerEffect = vi.fn(); const selectedLayer = makeImageLayer(); const faceDetections = [ { x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }, @@ -75,7 +88,8 @@ describe("useFaceBlurWorkflow", () => { selectedLayer, faceDetectionsLayerId: "layer-1", faceDetections, - setImageLayerFaceBlur, + setLayerEffect, + removeLayerEffect, }), ); @@ -93,10 +107,11 @@ describe("useFaceBlurWorkflow", () => { result.current.blurFaces(result.current.selectedFaceIndices); }); - expect(setImageLayerFaceBlur).toHaveBeenCalledTimes(1); - expect(setImageLayerFaceBlur).toHaveBeenCalledWith( + expect(setLayerEffect).toHaveBeenCalledTimes(1); + expect(setLayerEffect).toHaveBeenCalledWith( "layer-1", expect.objectContaining({ + kind: "face-blur", method: "gaussian", amount: 14, regions: expect.arrayContaining([ diff --git a/apps/web/hooks/use-face-blur-workflow.ts b/apps/web/hooks/use-face-blur-workflow.ts index cf06531..12e809b 100644 --- a/apps/web/hooks/use-face-blur-workflow.ts +++ b/apps/web/hooks/use-face-blur-workflow.ts @@ -1,34 +1,37 @@ import React from "react"; -import type { FaceBlurMethod, Layer } from "@pien-studio/types"; +import type { FaceBlurMethod, Layer, LayerEffect } from "@pien-studio/types"; import type { FaceDetectionOverlay } from "./use-face-detection"; type FaceBlurPreview = { layerId: string; - method: FaceBlurMethod; - amount: number; - regions: { x: number; y: number; width: number; height: number; sourceWidth: number; sourceHeight: number; censorColor?: string }[]; - censorColor?: string; + effects: LayerEffect[]; }; type UseFaceBlurWorkflowOptions = { selectedLayer: Layer | null; faceDetectionsLayerId: string | null; faceDetections: FaceDetectionOverlay[]; - setImageLayerFaceBlur: (layerId: string, faceBlur: Layer["faceBlur"] | undefined) => void; + setLayerEffect: (layerId: string, effect: LayerEffect) => void; + removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void; }; export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) { - const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options; + const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options; const [blurMethod, setBlurMethod] = React.useState("gaussian"); const [blurAmount, setBlurAmount] = React.useState(14); const [censorColor, setCensorColor] = React.useState("#111111"); const [selectedFaceIndices, setSelectedFaceIndices] = React.useState([]); const [faceBlurPreview, setFaceBlurPreview] = React.useState(null); - const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "image" && faceDetectionsLayerId === selectedLayer.id); + const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id); + + const faceBlurEffect = React.useMemo( + () => selectedLayer?.effects.find((e): e is Extract => e.kind === "face-blur") ?? null, + [selectedLayer?.effects], + ); const buildBlurRegions = React.useCallback( (indices: number[]) => { - if (!selectedLayer || selectedLayer.type !== "image") return []; + if (!selectedLayer || selectedLayer.type !== "raster") return []; if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return []; const indexSet = new Set(indices); return faceDetections @@ -57,17 +60,19 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) { }, []); const clearBlur = React.useCallback(() => { - if (!selectedLayer || selectedLayer.type !== "image") return; - setImageLayerFaceBlur(selectedLayer.id, undefined); + if (!selectedLayer) return; + removeLayerEffect(selectedLayer.id, "face-blur"); setFaceBlurPreview(null); - }, [selectedLayer, setImageLayerFaceBlur]); + }, [selectedLayer, removeLayerEffect]); const blurFaces = React.useCallback( (indices: number[]) => { - if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) return; + if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return; if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return; const regions = buildBlurRegions(indices); - setImageLayerFaceBlur(selectedLayer.id, { + setLayerEffect(selectedLayer.id, { + kind: "face-blur", + enabled: true, method: blurMethod, amount: blurAmount, regions, @@ -76,7 +81,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) { setSelectedFaceIndices([]); setFaceBlurPreview(null); }, - [blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur], + [blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setLayerEffect], ); React.useEffect(() => { @@ -85,7 +90,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) { setFaceBlurPreview(null); return; } - if (selectedLayer?.faceBlur) { + if (faceBlurEffect) { setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : [])); return; } @@ -94,10 +99,10 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) { if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev; return next; }); - }, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]); + }, [faceDetections, hasDetectableSelection, faceBlurEffect]); React.useEffect(() => { - if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") { + if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") { setFaceBlurPreview(null); return; } @@ -109,10 +114,14 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) { setFaceBlurPreview({ layerId: selectedLayer.id, - method: blurMethod, - amount: blurAmount, - regions: buildBlurRegions(selectedFaceIndices), - censorColor, + effects: [{ + kind: "face-blur", + enabled: true, + method: blurMethod, + amount: blurAmount, + regions: buildBlurRegions(selectedFaceIndices), + censorColor, + }], }); }, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]); diff --git a/apps/web/lib/brush-painter.ts b/apps/web/lib/brush-painter.ts new file mode 100644 index 0000000..4cda8f2 --- /dev/null +++ b/apps/web/lib/brush-painter.ts @@ -0,0 +1,99 @@ +export type BrushOptions = { + color: string; + size: number; + opacity: number; + hardness: number; // 0–1: 0 = fully soft, 1 = hard edge +}; + +export type BrushStroke = { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + width: number; + height: number; +}; + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const clean = hex.replace("#", ""); + return { + r: parseInt(clean.slice(0, 2), 16), + g: parseInt(clean.slice(2, 4), 16), + b: parseInt(clean.slice(4, 6), 16), + }; +} + +function drawDab( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + options: BrushOptions, +) { + const r = options.size / 2; + const { r: cr, g: cg, b: cb } = hexToRgb(options.color); + + const gradient = ctx.createRadialGradient(x, y, 0, x, y, r); + const innerStop = Math.max(0, Math.min(1, options.hardness)); + + gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`); + gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.opacity})`); + gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`); + + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = gradient; + ctx.fill(); +} + +/** Creates a fresh stroke canvas sized to the layer. */ +export function createStroke(width: number, height: number): BrushStroke { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(width)); + canvas.height = Math.max(1, Math.round(height)); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Cannot create brush canvas context"); + return { canvas, ctx, width: canvas.width, height: canvas.height }; +} + +/** Paints a segment of a stroke from (x0,y0) to (x1,y1) using interpolated dabs. */ +export function paintSegment( + stroke: BrushStroke, + x0: number, + y0: number, + x1: number, + y1: number, + options: BrushOptions, +) { + const dx = x1 - x0; + const dy = y1 - y0; + const dist = Math.sqrt(dx * dx + dy * dy); + const step = Math.max(1, options.size * 0.25); + const steps = Math.max(1, Math.ceil(dist / step)); + + for (let i = 0; i <= steps; i++) { + const t = steps === 0 ? 0 : i / steps; + drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options); + } +} + +/** Merges stroke canvas on top of the source image and returns a data URL. */ +export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Cannot create merge canvas context")); + return; + } + ctx.drawImage(image, 0, 0); + // Scale stroke canvas to match image natural size + ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height); + resolve(canvas.toDataURL("image/png")); + }; + image.onerror = reject; + image.src = sourceUri; + }); +} diff --git a/apps/web/lib/canvas-geometry.test.ts b/apps/web/lib/canvas-geometry.test.ts index 338a23f..48efcd3 100644 --- a/apps/web/lib/canvas-geometry.test.ts +++ b/apps/web/lib/canvas-geometry.test.ts @@ -11,7 +11,7 @@ describe("buildFaceLabelOverlays", () => { const layers = [ { id: "layer-1", - type: "image" as const, + type: "raster" as const, x: 20, y: 30, width: 180, @@ -19,6 +19,8 @@ describe("buildFaceLabelOverlays", () => { scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, }, ]; diff --git a/apps/web/lib/canvas-geometry.ts b/apps/web/lib/canvas-geometry.ts index be65d5e..8715350 100644 --- a/apps/web/lib/canvas-geometry.ts +++ b/apps/web/lib/canvas-geometry.ts @@ -13,7 +13,7 @@ export function buildFaceLabelOverlays( const layer = layers.find((item) => item.id === faceOverlayLayerId); if (!layer) return []; - const isImage = layer.type === "image"; + const isImage = layer.type === "raster"; const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined); const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined); if (!layerWidth || !layerHeight) return []; diff --git a/apps/web/lib/editor-tool-controller.ts b/apps/web/lib/editor-tool-controller.ts index 5bad4c3..9de9bc7 100644 --- a/apps/web/lib/editor-tool-controller.ts +++ b/apps/web/lib/editor-tool-controller.ts @@ -1,4 +1,4 @@ -import type { EditorToolId } from "../store/editor-store"; +import type { EditorToolId } from "@pien-studio/editor-core"; export type ToolModeController = { kind: "mode"; @@ -22,6 +22,8 @@ type CreateEditorToolControllersOptions = { pointer: string; pan: string; face: string; + fill: string; + brush: string; text: string; image: string; }; @@ -32,6 +34,8 @@ export function createEditorToolControllers(options: CreateEditorToolControllers { kind: "mode", id: "pointer", label: options.labels.pointer }, { kind: "mode", id: "hand", label: options.labels.pan }, { kind: "mode", id: "face", label: options.labels.face }, + { kind: "mode", id: "fill", label: options.labels.fill }, + { kind: "mode", id: "brush", label: options.labels.brush }, { kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer }, { kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage }, ]; diff --git a/apps/web/lib/effects/face-blur.test.ts b/apps/web/lib/effects/face-blur.test.ts new file mode 100644 index 0000000..d094ae8 --- /dev/null +++ b/apps/web/lib/effects/face-blur.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; +import { faceBlurRenderer } from "./face-blur"; +import type { FaceBlurEffect } from "@pien-studio/types"; + +function makeContext() { + return { + fillStyle: "", + filter: "none", + imageSmoothingEnabled: true, + fillRect: vi.fn(), + drawImage: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + } as unknown as CanvasRenderingContext2D; +} + +function makeImage(width = 1200, height = 800) { + return { naturalWidth: width, naturalHeight: height } as HTMLImageElement; +} + +function makeContext2d(ctx: CanvasRenderingContext2D, image: HTMLImageElement, tw = 600, th = 400) { + return { ctx, image, targetWidth: tw, targetHeight: th }; +} + +describe("faceBlurRenderer.render (regions)", () => { + it("renders gaussian blur region with source dimensions", () => { + const ctx = makeContext(); + const image = makeImage(); + const effect: FaceBlurEffect = { + kind: "face-blur", + enabled: true, + method: "gaussian", + amount: 24, + regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }], + }; + faceBlurRenderer.render(makeContext2d(ctx, image), effect); + + expect(ctx.save).toHaveBeenCalledOnce(); + expect(ctx.filter).toBe("blur(24px)"); + expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100); + expect(ctx.restore).toHaveBeenCalledOnce(); + }); + + it("renders pixelate using sampled offscreen canvas", () => { + const doc = globalThis.document; + expect(doc).toBeDefined(); + if (!doc) return; + const ctx = makeContext(); + const image = makeImage(); + const sampleDrawImage = vi.fn(); + const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D; + const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement; + const nativeCreateElement = doc.createElement.bind(doc); + const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => { + if (tagName === "canvas") return sampleCanvas; + return nativeCreateElement(tagName); + }); + + const effect: FaceBlurEffect = { + kind: "face-blur", + enabled: true, + method: "pixelate", + amount: 10, + regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }], + }; + faceBlurRenderer.render(makeContext2d(ctx, image), effect); + + expect(sampleCanvas.width).toBe(16); + expect(sampleCanvas.height).toBe(12); + expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12); + expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60); + createElement.mockRestore(); + }); + + it("renders censor with region color priority", () => { + const ctx = makeContext(); + const image = makeImage(); + const effect: FaceBlurEffect = { + kind: "face-blur", + enabled: true, + method: "censor", + amount: 20, + censorColor: "#ff0000", + regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }], + }; + faceBlurRenderer.render(makeContext2d(ctx, image), effect); + + expect(ctx.fillStyle).toBe("#00ff00"); + expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25); + }); + + it("falls back to legacy region scaling when source dimensions are missing", () => { + const ctx = makeContext(); + const image = makeImage(2400, 1600); + const effect: FaceBlurEffect = { + kind: "face-blur", + enabled: true, + method: "gaussian", + amount: 16, + regions: [{ x: 100, y: 120, width: 300, height: 200 }], + }; + faceBlurRenderer.render(makeContext2d(ctx, image), effect); + + expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200); + }); +}); + +describe("faceBlurRenderer.renderLayer", () => { + it("applies blur to source-sized image before drawing the resized layer", () => { + const doc = globalThis.document; + expect(doc).toBeDefined(); + if (!doc) return; + const ctx = makeContext(); + const image = makeImage(); + const sourceDrawImage = vi.fn(); + const sourceCtx = { ...makeContext(), drawImage: sourceDrawImage } as unknown as CanvasRenderingContext2D; + const sourceCanvas = { width: 0, height: 0, getContext: vi.fn(() => sourceCtx) } as unknown as HTMLCanvasElement; + const nativeCreateElement = doc.createElement.bind(doc); + const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => { + if (tagName === "canvas") return sourceCanvas; + return nativeCreateElement(tagName); + }); + + const effect: FaceBlurEffect = { + kind: "face-blur", + enabled: true, + method: "gaussian", + amount: 24, + regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }], + }; + faceBlurRenderer.renderLayer(makeContext2d(ctx, image), effect); + + expect(sourceCanvas.width).toBe(1200); + expect(sourceCanvas.height).toBe(800); + expect(sourceDrawImage).toHaveBeenNthCalledWith(1, image, 0, 0, 1200, 800); + expect(sourceDrawImage).toHaveBeenNthCalledWith(2, image, 120, 80, 300, 200, 120, 80, 300, 200); + expect(ctx.drawImage).toHaveBeenCalledWith(sourceCanvas, 0, 0, 1200, 800, 0, 0, 600, 400); + createElement.mockRestore(); + }); + + it("uses a canvas-filter fallback for gaussian blur when filters are unavailable", () => { + const doc = globalThis.document; + expect(doc).toBeDefined(); + if (!doc) return; + const ctx = makeContext(); + delete (ctx as Partial).filter; + const image = makeImage(); + const regionDrawImage = vi.fn(); + const blurDrawImage = vi.fn(); + const regionCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: regionDrawImage } as unknown as CanvasRenderingContext2D; + const blurCtx = { ...makeContext(), clearRect: vi.fn(), drawImage: blurDrawImage } as unknown as CanvasRenderingContext2D; + const regionCanvas = { width: 0, height: 0, getContext: vi.fn(() => regionCtx) } as unknown as HTMLCanvasElement; + const blurCanvas = { width: 0, height: 0, getContext: vi.fn(() => blurCtx) } as unknown as HTMLCanvasElement; + const nativeCreateElement = doc.createElement.bind(doc); + const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => { + if (tagName !== "canvas") return nativeCreateElement(tagName); + return createElement.mock.calls.length === 1 ? regionCanvas : blurCanvas; + }); + + const effect: FaceBlurEffect = { + kind: "face-blur", + enabled: true, + method: "gaussian", + amount: 24, + regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }], + }; + faceBlurRenderer.render(makeContext2d(ctx, image), effect); + + expect(ctx.save).not.toHaveBeenCalled(); + expect(regionCanvas.width).toBe(150); + expect(regionCanvas.height).toBe(100); + expect(regionDrawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 0, 0, 150, 100); + expect(ctx.drawImage).toHaveBeenCalledWith(regionCanvas, 0, 0, 150, 100, 60, 40, 150, 100); + createElement.mockRestore(); + }); +}); diff --git a/apps/web/lib/face-blur-renderer.ts b/apps/web/lib/effects/face-blur.ts similarity index 51% rename from apps/web/lib/face-blur-renderer.ts rename to apps/web/lib/effects/face-blur.ts index 680acfa..3226250 100644 --- a/apps/web/lib/face-blur-renderer.ts +++ b/apps/web/lib/effects/face-blur.ts @@ -1,6 +1,5 @@ -import type { FaceBlurSettings } from "@pien-studio/types"; - -type BlurRegion = FaceBlurSettings["regions"][number]; +import type { FaceBlurEffect } from "@pien-studio/types"; +import type { EffectRenderer, EffectRenderContext } from "./types"; function drawPixelatedRegion( ctx: CanvasRenderingContext2D, @@ -21,36 +20,12 @@ function drawPixelatedRegion( const sampleCtx = sampleCanvas.getContext("2d"); if (!sampleCtx) return; sampleCtx.imageSmoothingEnabled = false; - sampleCtx.drawImage( - source, - sourceX, - sourceY, - sourceWidth, - sourceHeight, - 0, - 0, - sampleCanvas.width, - sampleCanvas.height, - ); + sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height); ctx.imageSmoothingEnabled = false; - ctx.drawImage( - sampleCanvas, - 0, - 0, - sampleCanvas.width, - sampleCanvas.height, - targetX, - targetY, - targetWidth, - targetHeight, - ); + ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight); ctx.imageSmoothingEnabled = true; } -function canUseCanvasFilter(ctx: CanvasRenderingContext2D) { - return "filter" in ctx && typeof ctx.filter === "string"; -} - function drawBlurredRegionFallback( ctx: CanvasRenderingContext2D, source: CanvasImageSource, @@ -69,86 +44,36 @@ function drawBlurredRegionFallback( regionCanvas.height = Math.max(1, Math.round(targetHeight)); const regionCtx = regionCanvas.getContext("2d"); if (!regionCtx) return; - - regionCtx.drawImage( - source, - sourceX, - sourceY, - sourceWidth, - sourceHeight, - 0, - 0, - regionCanvas.width, - regionCanvas.height, - ); - + regionCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, regionCanvas.width, regionCanvas.height); const scale = Math.max(0.04, Math.min(0.5, 1 / Math.max(2, amount / 2))); const blurCanvas = document.createElement("canvas"); blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale)); blurCanvas.height = Math.max(1, Math.round(regionCanvas.height * scale)); const blurCtx = blurCanvas.getContext("2d"); if (!blurCtx) return; - blurCtx.imageSmoothingEnabled = true; blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height); - regionCtx.imageSmoothingEnabled = true; for (let i = 0; i < 3; i++) { regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height); - regionCtx.drawImage( - blurCanvas, - 0, - 0, - blurCanvas.width, - blurCanvas.height, - 0, - 0, - regionCanvas.width, - regionCanvas.height, - ); + regionCtx.drawImage(blurCanvas, 0, 0, blurCanvas.width, blurCanvas.height, 0, 0, regionCanvas.width, regionCanvas.height); blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height); - blurCtx.drawImage( - regionCanvas, - 0, - 0, - regionCanvas.width, - regionCanvas.height, - 0, - 0, - blurCanvas.width, - blurCanvas.height, - ); + blurCtx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, 0, 0, blurCanvas.width, blurCanvas.height); } - - ctx.drawImage( - regionCanvas, - 0, - 0, - regionCanvas.width, - regionCanvas.height, - targetX, - targetY, - targetWidth, - targetHeight, - ); + ctx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, targetX, targetY, targetWidth, targetHeight); } -export function renderFaceBlurRegions( +function renderRegions( ctx: CanvasRenderingContext2D, image: HTMLImageElement, - blur: { - method: FaceBlurSettings["method"]; - amount: number; - regions: BlurRegion[]; - censorColor?: string; - }, + effect: FaceBlurEffect, targetWidth: number, targetHeight: number, -): void { - if (!blur.regions.length) return; +) { + if (!effect.regions.length) return; const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth); const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight); - for (const region of blur.regions) { + for (const region of effect.regions) { const sourceWidth = region.sourceWidth ?? 0; const sourceHeight = region.sourceHeight ?? 0; const hasSourceDims = sourceWidth > 0 && sourceHeight > 0; @@ -159,80 +84,42 @@ export function renderFaceBlurRegions( const y = Math.max(0, Math.floor(region.y * scaleY)); const w = Math.max(1, Math.floor(region.width * scaleX)); const h = Math.max(1, Math.floor(region.height * scaleY)); - const sx0 = hasSourceDims - ? region.x - : Math.max(0, Math.floor(region.x * legacyScaleX)); - const sy0 = hasSourceDims - ? region.y - : Math.max(0, Math.floor(region.y * legacyScaleY)); - const sw = hasSourceDims - ? region.width - : Math.max(1, Math.floor(region.width * legacyScaleX)); - const sh = hasSourceDims - ? region.height - : Math.max(1, Math.floor(region.height * legacyScaleY)); + const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX)); + const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY)); + const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX)); + const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY)); - if (blur.method === "censor") { - ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111"; + if (effect.method === "censor") { + ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111"; ctx.fillRect(x, y, w, h); continue; } - - if (blur.method === "pixelate") { - const pixelSize = Math.max(4, Math.round(blur.amount / 2)); - drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, pixelSize); + if (effect.method === "pixelate") { + drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, Math.max(4, Math.round(effect.amount / 2))); continue; } - - if (canUseCanvasFilter(ctx)) { + if ("filter" in ctx && typeof ctx.filter === "string") { ctx.save(); - ctx.filter = `blur(${blur.amount}px)`; + ctx.filter = `blur(${effect.amount}px)`; ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h); ctx.restore(); continue; } - - drawBlurredRegionFallback( - ctx, - image, - sx0, - sy0, - sw, - sh, - x, - y, - w, - h, - blur.amount, - ); + drawBlurredRegionFallback(ctx, image, sx0, sy0, sw, sh, x, y, w, h, effect.amount); } } -export function renderImageWithFaceBlur( - ctx: CanvasRenderingContext2D, - image: HTMLImageElement, - blur: - | { - method: FaceBlurSettings["method"]; - amount: number; - regions: BlurRegion[]; - censorColor?: string; - } - | undefined, - targetWidth: number, - targetHeight: number, -): void { - if (!blur || blur.regions.length === 0) { +function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) { + const { ctx, image, targetWidth, targetHeight } = context; + if (!effect.regions.length) { ctx.drawImage(image, 0, 0, targetWidth, targetHeight); return; } - const canBlurAtSourceSize = blur.regions.every( - (region) => (region.sourceWidth ?? 0) > 0 && (region.sourceHeight ?? 0) > 0, - ); - if (!canBlurAtSourceSize) { + const allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0); + if (!allHaveSourceDims) { ctx.drawImage(image, 0, 0, targetWidth, targetHeight); - renderFaceBlurRegions(ctx, image, blur, targetWidth, targetHeight); + renderRegions(ctx, image, effect, targetWidth, targetHeight); return; } @@ -244,24 +131,15 @@ export function renderImageWithFaceBlur( ctx.drawImage(image, 0, 0, targetWidth, targetHeight); return; } - sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height); - renderFaceBlurRegions( - sourceCtx, - image, - blur, - sourceCanvas.width, - sourceCanvas.height, - ); - ctx.drawImage( - sourceCanvas, - 0, - 0, - sourceCanvas.width, - sourceCanvas.height, - 0, - 0, - targetWidth, - targetHeight, - ); + renderRegions(sourceCtx, image, effect, sourceCanvas.width, sourceCanvas.height); + ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight); } + +export const faceBlurRenderer: EffectRenderer = { + kind: "face-blur", + render: ({ ctx, image, targetWidth, targetHeight }, effect) => { + renderRegions(ctx, image, effect, targetWidth, targetHeight); + }, + renderLayer, +}; diff --git a/apps/web/lib/effects/registry.ts b/apps/web/lib/effects/registry.ts new file mode 100644 index 0000000..e02822c --- /dev/null +++ b/apps/web/lib/effects/registry.ts @@ -0,0 +1,46 @@ +import type { LayerEffect } from "@pien-studio/types"; +import type { EffectRenderer, EffectRenderContext } from "./types"; +import { faceBlurRenderer } from "./face-blur"; + +const renderers: EffectRenderer[] = [faceBlurRenderer]; + +const effectRendererRegistry = new Map( + renderers.map((r) => [r.kind, r]), +); + +export function getEffectRenderer(kind: string): EffectRenderer | undefined { + return effectRendererRegistry.get(kind); +} + +/** Draws a layer image applying all its effects in order. */ +export function renderLayerWithEffects( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + effects: LayerEffect[], + targetWidth: number, + targetHeight: number, +): void { + const activeEffects = effects.filter((e) => { + if (e.enabled === false) return false; + return effectRendererRegistry.get(e.kind) !== undefined; + }); + + if (activeEffects.length === 0) { + ctx.drawImage(image, 0, 0, targetWidth, targetHeight); + return; + } + + const context: EffectRenderContext = { ctx, image, targetWidth, targetHeight }; + + // The first effect owns the full layer render (draws base image + applies itself) + const first = activeEffects[0]; + const firstRenderer = effectRendererRegistry.get(first.kind); + firstRenderer?.renderLayer(context, first as never); + + // Subsequent effects render on top (overlay only, no re-draw of base) + for (let i = 1; i < activeEffects.length; i++) { + const effect = activeEffects[i]; + const renderer = effectRendererRegistry.get(effect.kind); + renderer?.render(context, effect as never); + } +} diff --git a/apps/web/lib/effects/types.ts b/apps/web/lib/effects/types.ts new file mode 100644 index 0000000..5a3e783 --- /dev/null +++ b/apps/web/lib/effects/types.ts @@ -0,0 +1,16 @@ +import type { LayerEffect } from "@pien-studio/types"; + +export type EffectRenderContext = { + ctx: CanvasRenderingContext2D; + image: HTMLImageElement; + targetWidth: number; + targetHeight: number; +}; + +export type EffectRenderer = { + kind: T["kind"]; + /** Renders the effect onto the canvas. Called after the base image is drawn. */ + render: (context: EffectRenderContext, effect: T) => void; + /** Renders the full layer (image + effect). Called instead of a plain drawImage. */ + renderLayer: (context: EffectRenderContext, effect: T) => void; +}; diff --git a/apps/web/lib/export-png.ts b/apps/web/lib/export-png.ts index 88a273d..3e80b88 100644 --- a/apps/web/lib/export-png.ts +++ b/apps/web/lib/export-png.ts @@ -1,5 +1,5 @@ import type { Layer, Project } from "@pien-studio/types"; -import { renderImageWithFaceBlur } from "./face-blur-renderer"; +import { renderLayerWithEffects } from "./effects/registry"; type ExportOptions = { isDark: boolean; @@ -20,11 +20,7 @@ function loadImage(src: string) { }); } -function drawFallbackLayer( - ctx: CanvasRenderingContext2D, - layer: Layer, - isDark: boolean, -) { +function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) { const text = layer.name ?? layer.type; const width = Math.max(80, layer.width ?? 120); const height = Math.max(34, layer.height ?? 40); @@ -49,24 +45,15 @@ function drawFallbackLayer( ctx.stroke(); ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430"; - ctx.font = - "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif"; + ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, width / 2, height / 2); } -async function drawLayer( - ctx: CanvasRenderingContext2D, - layer: Layer, - isDark: boolean, -) { - const width = - layer.width ?? - (layer.type === "image" ? Math.round(200 * layer.scale) : 120); - const height = - layer.height ?? - (layer.type === "image" ? Math.round(150 * layer.scale) : 40); +async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) { + const width = layer.width ?? (layer.type === "raster" ? Math.round(200 * layer.scale) : 120); + const height = layer.height ?? (layer.type === "raster" ? Math.round(150 * layer.scale) : 40); ctx.save(); ctx.globalAlpha = clampOpacity(layer.opacity); @@ -74,10 +61,10 @@ async function drawLayer( ctx.rotate((layer.rotation * Math.PI) / 180); ctx.translate(-width / 2, -height / 2); - if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) { + if ((layer.type === "raster" || layer.type === "sticker") && layer.sourceUri) { try { const image = await loadImage(layer.sourceUri); - renderImageWithFaceBlur(ctx, image, layer.faceBlur, width, height); + renderLayerWithEffects(ctx, image, layer.effects, width, height); } catch { drawFallbackLayer(ctx, layer, isDark); } @@ -88,14 +75,8 @@ async function drawLayer( ctx.restore(); } -export async function exportProjectAsPng( - project: Project, - options: ExportOptions, -) { - const pixelRatio = Math.max( - 1, - Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1), - ); +export async function exportProjectAsPng(project: Project, options: ExportOptions) { + const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1)); const { width, height } = project.canvas; const canvas = document.createElement("canvas"); canvas.width = width * pixelRatio; @@ -103,10 +84,10 @@ export async function exportProjectAsPng( const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Cannot create export canvas context"); - ctx.scale(pixelRatio, pixelRatio); for (const layer of project.layers) { + if (layer.visible === false) continue; await drawLayer(ctx, layer, options.isDark); } diff --git a/apps/web/lib/face-blur-renderer.test.ts b/apps/web/lib/face-blur-renderer.test.ts deleted file mode 100644 index 5e55396..0000000 --- a/apps/web/lib/face-blur-renderer.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - renderFaceBlurRegions, - renderImageWithFaceBlur, -} from "./face-blur-renderer"; - -function makeContext() { - return { - fillStyle: "", - filter: "none", - imageSmoothingEnabled: true, - fillRect: vi.fn(), - drawImage: vi.fn(), - save: vi.fn(), - restore: vi.fn(), - } as unknown as CanvasRenderingContext2D; -} - -function makeImage(width = 1200, height = 800) { - return { naturalWidth: width, naturalHeight: height } as HTMLImageElement; -} - -describe("renderFaceBlurRegions", () => { - it("renders gaussian blur region with source dimensions", () => { - const ctx = makeContext(); - const image = makeImage(); - renderFaceBlurRegions( - ctx, - image, - { - method: "gaussian", - amount: 24, - regions: [ - { - x: 120, - y: 80, - width: 300, - height: 200, - sourceWidth: 1200, - sourceHeight: 800, - }, - ], - }, - 600, - 400, - ); - - expect(ctx.save).toHaveBeenCalledOnce(); - expect(ctx.filter).toBe("blur(24px)"); - expect(ctx.drawImage).toHaveBeenCalledWith( - image, - 120, - 80, - 300, - 200, - 60, - 40, - 150, - 100, - ); - expect(ctx.restore).toHaveBeenCalledOnce(); - }); - - it("renders pixelate using sampled offscreen canvas", () => { - const doc = globalThis.document; - expect(doc).toBeDefined(); - if (!doc) return; - const ctx = makeContext(); - const image = makeImage(); - const sampleDrawImage = vi.fn(); - const sampleCtx = { - imageSmoothingEnabled: true, - drawImage: sampleDrawImage, - } as unknown as CanvasRenderingContext2D; - const sampleCanvas = { - width: 0, - height: 0, - getContext: vi.fn(() => sampleCtx), - } as unknown as HTMLCanvasElement; - const nativeCreateElement = doc.createElement.bind(doc); - const createElement = vi - .spyOn(doc, "createElement") - .mockImplementation((tagName: string) => { - if (tagName === "canvas") return sampleCanvas; - return nativeCreateElement(tagName); - }); - - renderFaceBlurRegions( - ctx, - image, - { - method: "pixelate", - amount: 10, - regions: [ - { - x: 200, - y: 100, - width: 160, - height: 120, - sourceWidth: 1200, - sourceHeight: 800, - }, - ], - }, - 600, - 400, - ); - - expect(sampleCanvas.width).toBe(16); - expect(sampleCanvas.height).toBe(12); - expect(sampleDrawImage).toHaveBeenCalledWith( - image, - 200, - 100, - 160, - 120, - 0, - 0, - 16, - 12, - ); - expect(ctx.drawImage).toHaveBeenCalledWith( - sampleCanvas, - 0, - 0, - 16, - 12, - 100, - 50, - 80, - 60, - ); - createElement.mockRestore(); - }); - - it("renders censor with region color priority", () => { - const ctx = makeContext(); - const image = makeImage(); - renderFaceBlurRegions( - ctx, - image, - { - method: "censor", - amount: 20, - censorColor: "#ff0000", - regions: [ - { - x: 20, - y: 30, - width: 40, - height: 50, - sourceWidth: 1200, - sourceHeight: 800, - censorColor: "#00ff00", - }, - ], - }, - 600, - 400, - ); - - expect(ctx.fillStyle).toBe("#00ff00"); - expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25); - }); - - it("falls back to legacy region scaling when source dimensions are missing", () => { - const ctx = makeContext(); - const image = makeImage(2400, 1600); - renderFaceBlurRegions( - ctx, - image, - { - method: "gaussian", - amount: 16, - regions: [{ x: 100, y: 120, width: 300, height: 200 }], - }, - 600, - 400, - ); - - expect(ctx.drawImage).toHaveBeenCalledWith( - image, - 400, - 480, - 1200, - 800, - 100, - 120, - 300, - 200, - ); - }); - - it("applies blur to source-sized image before drawing the resized layer", () => { - const doc = globalThis.document; - expect(doc).toBeDefined(); - if (!doc) return; - const ctx = makeContext(); - const image = makeImage(); - const sourceDrawImage = vi.fn(); - const sourceCtx = { - ...makeContext(), - drawImage: sourceDrawImage, - } as unknown as CanvasRenderingContext2D; - const sourceCanvas = { - width: 0, - height: 0, - getContext: vi.fn(() => sourceCtx), - } as unknown as HTMLCanvasElement; - const nativeCreateElement = doc.createElement.bind(doc); - const createElement = vi - .spyOn(doc, "createElement") - .mockImplementation((tagName: string) => { - if (tagName === "canvas") return sourceCanvas; - return nativeCreateElement(tagName); - }); - - renderImageWithFaceBlur( - ctx, - image, - { - method: "gaussian", - amount: 24, - regions: [ - { - x: 120, - y: 80, - width: 300, - height: 200, - sourceWidth: 1200, - sourceHeight: 800, - }, - ], - }, - 600, - 400, - ); - - expect(sourceCanvas.width).toBe(1200); - expect(sourceCanvas.height).toBe(800); - expect(sourceDrawImage).toHaveBeenNthCalledWith(1, image, 0, 0, 1200, 800); - expect(sourceDrawImage).toHaveBeenNthCalledWith( - 2, - image, - 120, - 80, - 300, - 200, - 120, - 80, - 300, - 200, - ); - expect(ctx.drawImage).toHaveBeenCalledWith( - sourceCanvas, - 0, - 0, - 1200, - 800, - 0, - 0, - 600, - 400, - ); - createElement.mockRestore(); - }); - - it("uses a canvas-filter fallback for gaussian blur when filters are unavailable", () => { - const doc = globalThis.document; - expect(doc).toBeDefined(); - if (!doc) return; - const ctx = makeContext(); - delete (ctx as Partial).filter; - const image = makeImage(); - const regionDrawImage = vi.fn(); - const blurDrawImage = vi.fn(); - const regionCtx = { - ...makeContext(), - clearRect: vi.fn(), - drawImage: regionDrawImage, - } as unknown as CanvasRenderingContext2D; - const blurCtx = { - ...makeContext(), - clearRect: vi.fn(), - drawImage: blurDrawImage, - } as unknown as CanvasRenderingContext2D; - const regionCanvas = { - width: 0, - height: 0, - getContext: vi.fn(() => regionCtx), - } as unknown as HTMLCanvasElement; - const blurCanvas = { - width: 0, - height: 0, - getContext: vi.fn(() => blurCtx), - } as unknown as HTMLCanvasElement; - const nativeCreateElement = doc.createElement.bind(doc); - const createElement = vi - .spyOn(doc, "createElement") - .mockImplementation((tagName: string) => { - if (tagName !== "canvas") return nativeCreateElement(tagName); - return createElement.mock.calls.length === 1 - ? regionCanvas - : blurCanvas; - }); - - renderFaceBlurRegions( - ctx, - image, - { - method: "gaussian", - amount: 24, - regions: [ - { - x: 120, - y: 80, - width: 300, - height: 200, - sourceWidth: 1200, - sourceHeight: 800, - }, - ], - }, - 600, - 400, - ); - - expect(ctx.save).not.toHaveBeenCalled(); - expect(regionCanvas.width).toBe(150); - expect(regionCanvas.height).toBe(100); - expect(regionDrawImage).toHaveBeenCalledWith( - image, - 120, - 80, - 300, - 200, - 0, - 0, - 150, - 100, - ); - expect(ctx.drawImage).toHaveBeenCalledWith( - regionCanvas, - 0, - 0, - 150, - 100, - 60, - 40, - 150, - 100, - ); - createElement.mockRestore(); - }); -}); diff --git a/apps/web/lib/flood-fill.ts b/apps/web/lib/flood-fill.ts new file mode 100644 index 0000000..6350a5a --- /dev/null +++ b/apps/web/lib/flood-fill.ts @@ -0,0 +1,96 @@ +type RGBA = [number, number, number, number]; + +function colorDistance(a: RGBA, b: RGBA): number { + // Weight alpha at 25% so transparent regions fill correctly + return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]) * 0.25; +} + +function matchesTarget(pixel: RGBA, target: RGBA, tolerance: number): boolean { + // Fully transparent pixels all match each other regardless of RGB + if (target[3] === 0) return pixel[3] <= tolerance; + return colorDistance(pixel, target) <= tolerance; +} + +function hexToRgba(hex: string): RGBA { + const clean = hex.replace("#", ""); + const r = parseInt(clean.slice(0, 2), 16); + const g = parseInt(clean.slice(2, 4), 16); + const b = parseInt(clean.slice(4, 6), 16); + const a = clean.length === 8 ? parseInt(clean.slice(6, 8), 16) : 255; + return [r, g, b, a]; +} + +export function floodFillDataUrl( + sourceUri: string, + px: number, + py: number, + fillColor: string, + tolerance: number = 32, +): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Cannot get canvas context")); + return; + } + + ctx.drawImage(image, 0, 0); + const { width, height } = canvas; + const data = ctx.getImageData(0, 0, width, height); + const pixels = data.data; + + const x = Math.round(px); + const y = Math.round(py); + if (x < 0 || x >= width || y < 0 || y >= height) { + resolve(sourceUri); + return; + } + + const idx = (y * width + x) * 4; + const target: RGBA = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]]; + const fill = hexToRgba(fillColor); + + if (matchesTarget(target, fill, 0)) { + resolve(sourceUri); + return; + } + + const visited = new Uint8Array(width * height); + const stack: number[] = [x + y * width]; + + while (stack.length > 0) { + const pos = stack.pop()!; + if (visited[pos]) continue; + visited[pos] = 1; + + const cx = pos % width; + const cy = Math.floor(pos / width); + const ci = pos * 4; + const current: RGBA = [pixels[ci], pixels[ci + 1], pixels[ci + 2], pixels[ci + 3]]; + + if (!matchesTarget(current, target, tolerance)) continue; + + pixels[ci] = fill[0]; + pixels[ci + 1] = fill[1]; + pixels[ci + 2] = fill[2]; + pixels[ci + 3] = fill[3]; + + if (cx > 0) stack.push(pos - 1); + if (cx < width - 1) stack.push(pos + 1); + if (cy > 0) stack.push(pos - width); + if (cy < height - 1) stack.push(pos + width); + } + + ctx.putImageData(data, 0, 0); + resolve(canvas.toDataURL("image/png")); + }; + image.onerror = reject; + image.src = sourceUri; + }); +} diff --git a/apps/web/lib/project-equality.test.ts b/apps/web/lib/project-equality.test.ts index 54dfbf1..0276972 100644 --- a/apps/web/lib/project-equality.test.ts +++ b/apps/web/lib/project-equality.test.ts @@ -10,7 +10,7 @@ function makeProject(): Project { updatedAt: "2024-01-01T00:00:00.000Z", aspectRatio: "1:1", canvas: { width: 100, height: 100, unit: "px" }, - layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1 }], + layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true }], }; } @@ -31,18 +31,18 @@ describe("hasProjectChanged", () => { it("detects face blur region changes", () => { const a = makeProject(); const b = makeProject(); - a.layers[0].type = "image"; - b.layers[0].type = "image"; - a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }; - b.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] }; + a.layers[0].type = "raster"; + b.layers[0].type = "raster"; + a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }]; + b.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] }]; expect(hasProjectChanged(a, b)).toBe(true); }); it("detects layer order changes", () => { const a = makeProject(); const b = makeProject(); - a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 }); - b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 }); + a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true }); + b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true }); b.layers = [b.layers[1], b.layers[0]]; expect(hasProjectChanged(a, b)).toBe(true); }); @@ -58,9 +58,9 @@ describe("hasProjectChanged", () => { it("detects face blur removal", () => { const a = makeProject(); const b = makeProject(); - a.layers[0].type = "image"; - b.layers[0].type = "image"; - a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }; + a.layers[0].type = "raster"; + b.layers[0].type = "raster"; + a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }]; expect(hasProjectChanged(a, b)).toBe(true); }); }); diff --git a/apps/web/lib/project-equality.ts b/apps/web/lib/project-equality.ts index 7bcb736..156bb0f 100644 --- a/apps/web/lib/project-equality.ts +++ b/apps/web/lib/project-equality.ts @@ -32,26 +32,7 @@ export function hasProjectChanged(left: Project, right: Project): boolean { return true; } - const blurA = a.faceBlur; - const blurB = b.faceBlur; - if (!blurA && !blurB) continue; - if (!blurA || !blurB) return true; - if (blurA.method !== blurB.method || blurA.amount !== blurB.amount || blurA.censorColor !== blurB.censorColor) return true; - if (blurA.regions.length !== blurB.regions.length) return true; - for (let regionIndex = 0; regionIndex < blurA.regions.length; regionIndex += 1) { - const regionA = blurA.regions[regionIndex]; - const regionB = blurB.regions[regionIndex]; - if (!regionA || !regionB) return true; - if ( - regionA.x !== regionB.x || - regionA.y !== regionB.y || - regionA.width !== regionB.width || - regionA.height !== regionB.height || - regionA.censorColor !== regionB.censorColor - ) { - return true; - } - } + if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true; } return false; diff --git a/apps/web/lib/tools/registry.ts b/apps/web/lib/tools/registry.ts new file mode 100644 index 0000000..e48c079 --- /dev/null +++ b/apps/web/lib/tools/registry.ts @@ -0,0 +1,66 @@ +import type { ToolUiDefinition } from "./types"; + +const definitions: ToolUiDefinition[] = [ + { + id: "pointer", + interactionMode: "select", + allowsLayerDrag: true, + allowsLayerResize: true, + allowsLayerRotate: true, + iconName: "MousePointer2", + cursor: "default", + labelKey: "editor.toolPointer", + }, + { + id: "hand", + interactionMode: "pan", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + iconName: "Hand", + cursor: "grab", + labelKey: "editor.toolPan", + }, + { + id: "face", + interactionMode: "annotate", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + iconName: "ScanFace", + cursor: "default", + labelKey: "editor.toolFace", + }, + { + id: "fill", + interactionMode: "paint", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + iconName: "PaintBucket", + cursor: "crosshair", + labelKey: "editor.toolFill", + }, + { + id: "brush", + interactionMode: "paint", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + iconName: "Brush", + cursor: "crosshair", + labelKey: "editor.toolBrush", + }, +]; + +const toolUiRegistry = new Map( + definitions.map((d) => [d.id, d]), +); + +export function getToolUiDefinition(id: string): ToolUiDefinition | undefined { + return toolUiRegistry.get(id); +} + +export function getAllToolUiDefinitions(): ToolUiDefinition[] { + return definitions; +} diff --git a/apps/web/lib/tools/types.ts b/apps/web/lib/tools/types.ts new file mode 100644 index 0000000..01ac01a --- /dev/null +++ b/apps/web/lib/tools/types.ts @@ -0,0 +1,10 @@ +import type { ToolDefinition } from "@pien-studio/editor-core"; + +export type ToolUiDefinition = ToolDefinition & { + /** Lucide icon component name (resolved at render time) */ + iconName: string; + /** CSS cursor when this tool is active */ + cursor: string; + /** i18n key for the toolbar label */ + labelKey: string; +}; diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 88db35c..66fa4f2 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -47,6 +47,7 @@ "unsavedChanges": "Unsaved changes", "saved": "Saved", "layers": "Layers", + "newLayer": "New layer", "up": "Up", "down": "Down", "delete": "Delete", @@ -86,9 +87,13 @@ "faceDetectionTip": "{count} face(s) found. Select an image layer with a clear, front-facing face for best results.", "toolPointer": "Pointer", "toolPan": "Pan", - "toolFace": "Face", + "toolFace": "Face Blur", + "toolFill": "Fill", + "toolBrush": "Brush", "toolText": "Text", "toolImage": "Image", + "fillColor": "Fill color", + "fillTolerance": "Tolerance", "canvasSizeTitle": "Canvas Size", "modePreset": "Preset", "modeCustom": "Custom", diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 0ba8fbd..63faa0b 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -47,6 +47,7 @@ "unsavedChanges": "未保存の変更", "saved": "保存済み", "layers": "レイヤー", + "newLayer": "新しいレイヤー", "up": "上へ", "down": "下へ", "delete": "削除", @@ -86,9 +87,13 @@ "faceDetectionTip": "{count}件の顔が見つかりました。正面を向いた顔がはっきり写っている画像レイヤーを選ぶと、より良い結果になります。", "toolPointer": "ポインター", "toolPan": "パン", - "toolFace": "顔", + "toolFace": "顔ぼかし", + "toolFill": "塗りつぶし", + "toolBrush": "ブラシ", "toolText": "テキスト", "toolImage": "画像", + "fillColor": "塗りつぶし色", + "fillTolerance": "許容値", "canvasSizeTitle": "キャンバスサイズ", "modePreset": "プリセット", "modeCustom": "カスタム", diff --git a/apps/web/messages/th.json b/apps/web/messages/th.json index 8c1f253..ba80085 100644 --- a/apps/web/messages/th.json +++ b/apps/web/messages/th.json @@ -47,6 +47,7 @@ "unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่บันทึก", "saved": "บันทึกแล้ว", "layers": "เลเยอร์", + "newLayer": "เลเยอร์ใหม่", "up": "ขึ้น", "down": "ลง", "delete": "ลบ", @@ -86,9 +87,13 @@ "faceDetectionTip": "พบ {count} ใบหน้า เลือกเลเยอร์รูปภาพที่มีใบหน้าหันตรงและชัดเจนเพื่อผลลัพธ์ที่ดีที่สุด", "toolPointer": "ตัวชี้", "toolPan": "เลื่อน", - "toolFace": "ใบหน้า", + "toolFace": "เบลอใบหน้า", + "toolFill": "เติมสี", + "toolBrush": "แปรง", "toolText": "ข้อความ", "toolImage": "รูปภาพ", + "fillColor": "สีเติม", + "fillTolerance": "ความคลาดเคลื่อน", "canvasSizeTitle": "ขนาดแคนวาส", "modePreset": "พรีเซ็ต", "modeCustom": "กำหนดเอง", diff --git a/apps/web/store/editor-store.test.ts b/apps/web/store/editor-store.test.ts index 46713d4..86d091b 100644 --- a/apps/web/store/editor-store.test.ts +++ b/apps/web/store/editor-store.test.ts @@ -17,7 +17,7 @@ describe("editor store", () => { it("imports and exports project json", () => { useEditorStore.getState().resetProject(); - useEditorStore.getState().addLayerByType("image"); + useEditorStore.getState().addLayerByType("raster"); const json = useEditorStore.getState().exportProjectToJson(); useEditorStore.getState().resetProject(); @@ -46,7 +46,7 @@ describe("editor store", () => { it("records pointer draft transforms in undo history on commit", () => { useEditorStore.getState().resetProject(); - useEditorStore.getState().addLayerByType("image"); + useEditorStore.getState().addLayerByType("raster"); const initial = useEditorStore.getState().project.layers[0]; expect(initial).toBeDefined(); @@ -63,7 +63,7 @@ describe("editor store", () => { it("reorders layers up and down", () => { useEditorStore.getState().resetProject(); useEditorStore.getState().addLayerByType("text"); - useEditorStore.getState().addLayerByType("image"); + useEditorStore.getState().addLayerByType("raster"); const layers = useEditorStore.getState().project.layers; const [first, second] = layers; expect(first.id).not.toBe(second.id); diff --git a/apps/web/store/editor-store.ts b/apps/web/store/editor-store.ts index 1524cd4..0378776 100644 --- a/apps/web/store/editor-store.ts +++ b/apps/web/store/editor-store.ts @@ -10,10 +10,16 @@ import { reorderLayer, serializeProjectFile, setCanvasSize as applyCanvasSize, + setLayerEffect, + removeLayerEffect, + setLayerVisible, + setEffectEnabled, updateLayerTransform, normalizeProject, + getAllTools, + type EditorToolId, } from "@pien-studio/editor-core"; -import type { FaceBlurSettings, Layer, Project } from "@pien-studio/types"; +import type { Layer, LayerEffect, Project } from "@pien-studio/types"; import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage"; import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants"; import { hasProjectChanged } from "../lib/project-equality"; @@ -29,13 +35,10 @@ import { const DRAFT_TRANSFORM_EPSILON = 0.01; -export const EDITOR_TOOLS = { - pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true }, - hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false }, - face: { id: "face", allowsSelection: true, allowsLayerEditing: false }, -} as const; +export { EditorToolId }; -export type EditorToolId = keyof typeof EDITOR_TOOLS; +const allTools = getAllTools(); +export const EDITOR_TOOLS = Object.fromEntries(allTools.map((t) => [t.id, t])) as Record; type TransactionState = { baselineProject: Project; @@ -52,7 +55,6 @@ type EditorState = { clipboardLayer: Layer | null; isDirty: boolean; transaction: TransactionState | null; - tools: typeof EDITOR_TOOLS; startTransaction: () => void; commitTransaction: () => void; cancelTransaction: () => void; @@ -70,15 +72,19 @@ type EditorState = { selectLayer: (layerId: string | null) => void; copySelectedLayer: () => void; cutSelectedLayer: () => void; - pasteLayer: () => void; + pasteLayer: (e?: ClipboardEvent) => void; resetProject: () => void; saveCurrentProject: () => Promise; loadProjectById: (projectId: string) => Promise; setProject: (project: Project) => void; importProjectFromJson: (raw: string) => { ok: boolean; error?: string }; + addCanvasSizedLayer: (sourceUri: string, name?: string) => void; importImageFromFile: (file: File) => Promise; updateImageLayerSource: (layerId: string, sourceUri: string) => void; - setImageLayerFaceBlur: (layerId: string, faceBlur: FaceBlurSettings | undefined) => void; + setLayerEffect: (layerId: string, effect: LayerEffect) => void; + removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void; + setLayerVisible: (layerId: string, visible: boolean) => void; + setEffectEnabled: (layerId: string, kind: LayerEffect["kind"], enabled: boolean) => void; setCanvasSize: (width: number, height: number) => void; exportProjectToJson: () => string; undo: () => void; @@ -129,7 +135,6 @@ export const useEditorStore = create((set, get) => ({ clipboardLayer: null, isDirty: false, transaction: null, - tools: EDITOR_TOOLS, startTransaction: () => set((state) => { @@ -172,7 +177,7 @@ export const useEditorStore = create((set, get) => ({ applyProjectDraft: (project) => set(() => ({ project })), setTool: (tool) => { - if (!(tool in EDITOR_TOOLS)) return; + if (!EDITOR_TOOLS[tool]) return; set({ tool }); }, @@ -183,6 +188,14 @@ export const useEditorStore = create((set, get) => ({ return withCommittedProject(state, nextProject, { selectedLayerId: layer.id }); }), + addCanvasSizedLayer: (sourceUri, name) => + set((state) => { + const { width, height } = state.project.canvas; + const layer = createLayer("raster", { name: name ?? "Layer", sourceUri, x: 0, y: 0, width, height }); + const nextProject = addLayer(state.project, layer); + return withCommittedProject(state, nextProject, { selectedLayerId: layer.id }); + }), + setSelectedLayerPosition: (x, y) => set((state) => { if (!state.selectedLayerId) return state; @@ -218,8 +231,8 @@ export const useEditorStore = create((set, get) => ({ const nextHeight = Math.max(MIN_LAYER_SIZE, height); if (current) { - const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined); - const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined); + const currentWidth = current.width ?? (current.type === "raster" ? Math.round(200 * current.scale) : undefined); + const currentHeight = current.height ?? (current.type === "raster" ? Math.round(150 * current.scale) : undefined); if ( typeof currentWidth === "number" && @@ -284,67 +297,64 @@ export const useEditorStore = create((set, get) => ({ return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) }); }), - pasteLayer: () => { + pasteLayer: (e?: ClipboardEvent) => { const state = get(); - if (!state.clipboardLayer) { - navigator.clipboard.read().then(async (clipboardItems) => { - for (const item of clipboardItems) { - for (const type of item.types) { - if (type.startsWith("image/")) { - const blob = await item.getType(type); - const reader = new FileReader(); - const dataUrl = await new Promise((resolve, reject) => { - reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - const imageSize = await new Promise<{ width: number; height: number }>((resolve) => { - const image = new Image(); - image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); - image.onerror = () => - resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight }); - image.src = dataUrl; - }); - const layer = createLayer("image", { - name: "Image", - sourceUri: dataUrl, - x: DEFAULT_IMAGE_IMPORT.offsetX, - y: DEFAULT_IMAGE_IMPORT.offsetY, - width: Math.max(1, Math.round(imageSize.width)), - height: Math.max(1, Math.round(imageSize.height)), - }); - set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id })); - return; - } - } - } - navigator.clipboard.readText().then((text) => { - if (text) { - const trimmed = text.trim(); - if (trimmed.startsWith("data:image") || trimmed.startsWith("http") || trimmed.startsWith("blob:")) { - const image = new Image(); - image.onload = () => { - const layer = createLayer("image", { - name: "Image", - sourceUri: trimmed, - x: DEFAULT_IMAGE_IMPORT.offsetX, - y: DEFAULT_IMAGE_IMPORT.offsetY, - width: Math.max(1, Math.round(image.naturalWidth)), - height: Math.max(1, Math.round(image.naturalHeight)), - }); - set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id })); - }; - image.src = trimmed; - } - } - }); - }).catch(() => {}); + + // Internal layer clipboard takes priority + if (state.clipboardLayer) { + const base = state.clipboardLayer; + const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 }; + set((s) => withCommittedProject(s, addLayer(s.project, pasted), { selectedLayerId: pasted.id })); return; } - const base = state.clipboardLayer; - const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 }; - const nextProject = addLayer(state.project, pasted); - set((s) => withCommittedProject(s, nextProject, { selectedLayerId: pasted.id })); + + async function pasteImageBlob(blob: Blob) { + const reader = new FileReader(); + const dataUrl = await new Promise((resolve, reject) => { + reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + const imageSize = await new Promise<{ width: number; height: number }>((resolve) => { + const image = new Image(); + image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); + image.onerror = () => resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight }); + image.src = dataUrl; + }); + const layer = createLayer("raster", { + name: "Image", + sourceUri: dataUrl, + x: DEFAULT_IMAGE_IMPORT.offsetX, + y: DEFAULT_IMAGE_IMPORT.offsetY, + width: Math.max(1, Math.round(imageSize.width)), + height: Math.max(1, Math.round(imageSize.height)), + }); + set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id })); + } + + // Read from native ClipboardEvent.clipboardData (works on all browsers without permission prompt) + if (e?.clipboardData) { + for (const item of Array.from(e.clipboardData.items)) { + if (item.type.startsWith("image/")) { + const blob = item.getAsFile(); + if (blob) { void pasteImageBlob(blob); return; } + } + } + return; + } + + // Fallback: async Clipboard API (requires permission, may not work on Mac Safari) + navigator.clipboard.read().then(async (clipboardItems) => { + for (const item of clipboardItems) { + for (const type of item.types) { + if (type.startsWith("image/")) { + const blob = await item.getType(type); + await pasteImageBlob(blob); + return; + } + } + } + }).catch(() => {}); }, resetProject: () => { @@ -412,7 +422,7 @@ export const useEditorStore = create((set, get) => ({ resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight }); image.src = dataUrl; }); - const layer = createLayer("image", { + const layer = createLayer("raster", { name, sourceUri: dataUrl, x: DEFAULT_IMAGE_IMPORT.offsetX, @@ -428,20 +438,32 @@ export const useEditorStore = create((set, get) => ({ set((state) => { if (!sourceUri) return state; const layer = state.project.layers.find((item) => item.id === layerId); - if (!layer || layer.type !== "image") return state; + if (!layer || layer.type !== "raster") return state; if (layer.sourceUri === sourceUri) return state; const nextProject = updateLayerTransform(state.project, layerId, { sourceUri }); return withCommittedProject(state, nextProject); }), - setImageLayerFaceBlur: (layerId, faceBlur) => + setLayerEffect: (layerId, effect) => set((state) => { const layer = state.project.layers.find((item) => item.id === layerId); - if (!layer || layer.type !== "image") return state; - const nextProject = updateLayerTransform(state.project, layerId, { faceBlur }); - return withCommittedProject(state, nextProject); + if (!layer) return state; + return withCommittedProject(state, setLayerEffect(state.project, layerId, effect)); }), + removeLayerEffect: (layerId, kind) => + set((state) => { + const layer = state.project.layers.find((item) => item.id === layerId); + if (!layer) return state; + return withCommittedProject(state, removeLayerEffect(state.project, layerId, kind)); + }), + + setLayerVisible: (layerId, visible) => + set((state) => withCommittedProject(state, setLayerVisible(state.project, layerId, visible))), + + setEffectEnabled: (layerId, kind, enabled) => + set((state) => withCommittedProject(state, setEffectEnabled(state.project, layerId, kind, enabled))), + setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))), undo: () => diff --git a/packages/editor-core/src/effects/face-blur.ts b/packages/editor-core/src/effects/face-blur.ts new file mode 100644 index 0000000..8af2ce2 --- /dev/null +++ b/packages/editor-core/src/effects/face-blur.ts @@ -0,0 +1,23 @@ +import type { FaceBlurEffect } from "@pien-studio/types"; +import type { EffectDefinition } from "./registry"; + +function normalizeFaceBlur(effect: FaceBlurEffect): FaceBlurEffect { + return { + ...effect, + amount: Math.max(4, Math.min(40, Math.round(effect.amount))), + regions: effect.regions.map((region) => ({ + x: Number.isFinite(region.x) ? region.x : 0, + y: Number.isFinite(region.y) ? region.y : 0, + width: Math.max(1, Number.isFinite(region.width) ? region.width : 1), + height: Math.max(1, Number.isFinite(region.height) ? region.height : 1), + sourceWidth: Number.isFinite(region.sourceWidth ?? NaN) ? region.sourceWidth : undefined, + sourceHeight: Number.isFinite(region.sourceHeight ?? NaN) ? region.sourceHeight : undefined, + censorColor: region.censorColor, + })), + }; +} + +export const faceBlurDefinition: EffectDefinition = { + kind: "face-blur", + normalize: normalizeFaceBlur, +}; diff --git a/packages/editor-core/src/effects/registry.ts b/packages/editor-core/src/effects/registry.ts new file mode 100644 index 0000000..a64df23 --- /dev/null +++ b/packages/editor-core/src/effects/registry.ts @@ -0,0 +1,23 @@ +import type { LayerEffect } from "@pien-studio/types"; +import { faceBlurDefinition } from "./face-blur"; + +export type EffectDefinition = { + kind: T["kind"]; + normalize: (effect: T) => T; +}; + +const definitions = [faceBlurDefinition] as EffectDefinition[]; + +const effectRegistry = new Map( + definitions.map((def) => [def.kind, def]), +); + +export function getEffectDefinition(kind: string): EffectDefinition | undefined { + return effectRegistry.get(kind); +} + +export function normalizeEffect(effect: LayerEffect): LayerEffect { + const def = effectRegistry.get(effect.kind); + if (!def) return effect; + return def.normalize(effect as never); +} diff --git a/packages/editor-core/src/index.test.ts b/packages/editor-core/src/index.test.ts index 97f1974..7585a4a 100644 --- a/packages/editor-core/src/index.test.ts +++ b/packages/editor-core/src/index.test.ts @@ -19,12 +19,14 @@ describe("editor-core", () => { const project = createProject("new"); const updated = addLayer(project, { id: "l1", - type: "image", + type: "raster", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, }); expect(updated.layers).toHaveLength(1); @@ -41,6 +43,8 @@ describe("editor-core", () => { scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, }); const moved = moveLayer(project, "l1", { dx: 15, dy: -5 }); @@ -57,6 +61,8 @@ describe("editor-core", () => { scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, }); const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 }); @@ -74,6 +80,8 @@ describe("editor-core", () => { scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, }); const withSecond = addLayer(withFirst, { id: "l2", @@ -83,6 +91,8 @@ describe("editor-core", () => { scale: 1, rotation: 0, opacity: 1, + effects: [], + visible: true, }); const reordered = reorderLayer(withSecond, "l1", 1); diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index c8fb9df..6681e57 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -1,212 +1,36 @@ -import { - PRESET_CANVAS_SIZES, - ProjectFileSchema, - type AspectRatio, - type FaceBlurSettings, - type Layer, - type Project, - type ProjectFile, -} from "@pien-studio/types"; +export { createProject, setCanvasSize } from "./project"; +export { createLayer, addLayer, removeLayer, moveLayer, updateLayerTransform, reorderLayer, setLayerEffect, removeLayerEffect, setLayerVisible, setEffectEnabled } from "./layers"; +export type { LayerFactoryOptions, TransformPatch } from "./layers"; +export { normalizeProject } from "./normalize"; +export { serializeProjectFile, parseProjectFile } from "./serialization"; +export { getEffectDefinition, normalizeEffect } from "./effects/registry"; +export type { EffectDefinition } from "./effects/registry"; +export { faceBlurDefinition } from "./effects/face-blur"; +export { getToolDefinition, getAllTools } from "./tools/registry"; +export type { ToolDefinition, ToolInteractionMode, EditorToolId } from "./tools/registry"; -const DEFAULT_ASPECT: AspectRatio = "4:5"; - -function defaultCanvas(aspect: AspectRatio) { - const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1]; - return { width: preset.width, height: preset.height, unit: "px" as const }; -} - -export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project { - const now = new Date().toISOString(); - return { - id: crypto.randomUUID(), - title, - createdAt: now, - updatedAt: now, - canvas: defaultCanvas(aspect), - aspectRatio: aspect, - layers: [], - }; -} - -function withUpdatedTimestamp(project: Project, layers: Layer[]): Project { - return { ...project, layers, updatedAt: new Date().toISOString() }; -} - -type Delta = { dx: number; dy: number }; -export type TransformPatch = { - x?: number; - y?: number; - width?: number; - height?: number; - scale?: number; - rotation?: number; - opacity?: number; - sourceUri?: string; - faceBlur?: FaceBlurSettings; -}; - -export type LayerFactoryOptions = { - id?: string; - name?: string; - sourceUri?: string; - x?: number; - y?: number; - width?: number; - height?: number; -}; +import type { Layer, LayerEffect, Project } from "@pien-studio/types"; +import { addLayer, moveLayer, removeLayer, reorderLayer, setLayerEffect, updateLayerTransform } from "./layers"; +import { setCanvasSize } from "./project"; export type EditorOperation = | { type: "addLayer"; layer: Layer } | { type: "removeLayer"; layerId: string } - | { type: "moveLayer"; layerId: string; delta: Delta } - | { type: "updateLayerTransform"; layerId: string; patch: TransformPatch } + | { type: "moveLayer"; layerId: string; delta: { dx: number; dy: number } } + | { type: "updateLayerTransform"; layerId: string; patch: import("./layers").TransformPatch } | { type: "reorderLayer"; layerId: string; toIndex: number } + | { type: "setLayerEffect"; layerId: string; effect: LayerEffect } | { type: "setCanvasSize"; width: number; height: number; unit?: "px" | "in" | "cm" }; -export function normalizeProject(project: Project): Project { - const normalizedLayers = project.layers.map((layer) => ({ - ...layer, - width: layer.width !== undefined ? Math.max(1, Math.round(layer.width)) : undefined, - height: layer.height !== undefined ? Math.max(1, Math.round(layer.height)) : undefined, - scale: Number.isFinite(layer.scale) ? layer.scale : 1, - rotation: Number.isFinite(layer.rotation) ? layer.rotation : 0, - opacity: Number.isFinite(layer.opacity) ? Math.max(0, Math.min(1, layer.opacity)) : 1, - faceBlur: - layer.faceBlur && Array.isArray(layer.faceBlur.regions) - ? { - method: layer.faceBlur.method, - amount: Math.max(4, Math.min(40, Math.round(layer.faceBlur.amount))), - regions: layer.faceBlur.regions.map((region) => ({ - x: Number.isFinite(region.x) ? region.x : 0, - y: Number.isFinite(region.y) ? region.y : 0, - width: Math.max(1, Number.isFinite(region.width) ? region.width : 1), - height: Math.max(1, Number.isFinite(region.height) ? region.height : 1), - sourceWidth: Number.isFinite(region.sourceWidth) ? region.sourceWidth : undefined, - sourceHeight: Number.isFinite(region.sourceHeight) ? region.sourceHeight : undefined, - censorColor: region.censorColor, - })), - censorColor: layer.faceBlur.censorColor, - } - : undefined, - })); - - return { - ...project, - canvas: { - width: Math.max(1, Math.round(project.canvas.width)), - height: Math.max(1, Math.round(project.canvas.height)), - unit: project.canvas.unit, - }, - layers: normalizedLayers, - }; -} - -export function addLayer(project: Project, layer: Layer): Project { - return withUpdatedTimestamp(project, [...project.layers, layer]); -} - -export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer { - return { - id: options.id ?? crypto.randomUUID(), - type, - name: options.name, - sourceUri: options.sourceUri, - x: options.x ?? 110, - y: options.y ?? 90, - width: options.width, - height: options.height, - scale: 1, - rotation: 0, - opacity: 1, - }; -} - -export function removeLayer(project: Project, layerId: string): Project { - const layers = project.layers.filter((layer) => layer.id !== layerId); - return withUpdatedTimestamp(project, layers); -} - -export function moveLayer(project: Project, layerId: string, delta: Delta): Project { - const layers = project.layers.map((layer) => { - if (layer.id !== layerId) return layer; - return { ...layer, x: layer.x + delta.dx, y: layer.y + delta.dy }; - }); - return withUpdatedTimestamp(project, layers); -} - -export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project { - const layers = project.layers.map((layer) => (layer.id === layerId ? { ...layer, ...patch } : layer)); - return withUpdatedTimestamp(project, layers); -} - -export function reorderLayer(project: Project, layerId: string, toIndex: number): Project { - const fromIndex = project.layers.findIndex((l) => l.id === layerId); - if (fromIndex < 0) return project; - const layers = [...project.layers]; - const [picked] = layers.splice(fromIndex, 1); - if (!picked) return project; - const bounded = Math.max(0, Math.min(toIndex, layers.length)); - layers.splice(bounded, 0, picked); - return withUpdatedTimestamp(project, layers); -} - -export function setCanvasSize(project: Project, width: number, height: number, unit: "px" | "in" | "cm" = "px"): Project { - return { - ...project, - canvas: { - width: Math.max(1, Math.round(width)), - height: Math.max(1, Math.round(height)), - unit, - }, - updatedAt: new Date().toISOString(), - }; -} - export function applyOperation(project: Project, operation: EditorOperation): Project { switch (operation.type) { - case "addLayer": - return addLayer(project, operation.layer); - case "removeLayer": - return removeLayer(project, operation.layerId); - case "moveLayer": - return moveLayer(project, operation.layerId, operation.delta); - case "updateLayerTransform": - return updateLayerTransform(project, operation.layerId, operation.patch); - case "reorderLayer": - return reorderLayer(project, operation.layerId, operation.toIndex); - case "setCanvasSize": - return setCanvasSize(project, operation.width, operation.height, operation.unit); - default: - return project; - } -} - -export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string { - const document: ProjectFile = { - format: "pien.project", - version: 1, - exportedAt: new Date().toISOString(), - app: { name: "pien.studio", platform: "web" }, - project, - assets: [], - history: { - checkpointCount: options?.checkpointCount ?? 0, - }, - }; - - return JSON.stringify(document, null, 2); -} - -export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } { - try { - const data = JSON.parse(raw) as unknown; - const parsedEnvelope = ProjectFileSchema.safeParse(data); - if (parsedEnvelope.success) { - return { ok: true, project: normalizeProject(parsedEnvelope.data.project) }; - } - - return { ok: false, error: "Invalid project format" }; - } catch { - return { ok: false, error: "Invalid JSON" }; + case "addLayer": return addLayer(project, operation.layer); + case "removeLayer": return removeLayer(project, operation.layerId); + case "moveLayer": return moveLayer(project, operation.layerId, operation.delta); + case "updateLayerTransform": return updateLayerTransform(project, operation.layerId, operation.patch); + case "reorderLayer": return reorderLayer(project, operation.layerId, operation.toIndex); + case "setLayerEffect": return setLayerEffect(project, operation.layerId, operation.effect); + case "setCanvasSize": return setCanvasSize(project, operation.width, operation.height, operation.unit); + default: return project; } } diff --git a/packages/editor-core/src/layers.ts b/packages/editor-core/src/layers.ts new file mode 100644 index 0000000..020078f --- /dev/null +++ b/packages/editor-core/src/layers.ts @@ -0,0 +1,114 @@ +import type { Layer, LayerEffect, Project } from "@pien-studio/types"; + +export type LayerFactoryOptions = { + id?: string; + name?: string; + sourceUri?: string; + x?: number; + y?: number; + width?: number; + height?: number; +}; + +export type TransformPatch = { + x?: number; + y?: number; + width?: number; + height?: number; + scale?: number; + rotation?: number; + opacity?: number; + sourceUri?: string; + effects?: LayerEffect[]; +}; + +function withUpdatedAt(project: Project, layers: Layer[]): Project { + return { ...project, layers, updatedAt: new Date().toISOString() }; +} + +export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer { + return { + id: options.id ?? crypto.randomUUID(), + type, + name: options.name, + sourceUri: options.sourceUri, + effects: [], + visible: true, + x: options.x ?? 110, + y: options.y ?? 90, + width: options.width, + height: options.height, + scale: 1, + rotation: 0, + opacity: 1, + }; +} + +export function addLayer(project: Project, layer: Layer): Project { + return withUpdatedAt(project, [...project.layers, layer]); +} + +export function removeLayer(project: Project, layerId: string): Project { + return withUpdatedAt(project, project.layers.filter((l) => l.id !== layerId)); +} + +export function moveLayer(project: Project, layerId: string, delta: { dx: number; dy: number }): Project { + return withUpdatedAt( + project, + project.layers.map((l) => (l.id === layerId ? { ...l, x: l.x + delta.dx, y: l.y + delta.dy } : l)), + ); +} + +export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project { + return withUpdatedAt( + project, + project.layers.map((l) => (l.id === layerId ? { ...l, ...patch } : l)), + ); +} + +export function reorderLayer(project: Project, layerId: string, toIndex: number): Project { + const fromIndex = project.layers.findIndex((l) => l.id === layerId); + if (fromIndex < 0) return project; + const layers = [...project.layers]; + const [picked] = layers.splice(fromIndex, 1); + if (!picked) return project; + layers.splice(Math.max(0, Math.min(toIndex, layers.length)), 0, picked); + return withUpdatedAt(project, layers); +} + +export function setLayerEffect(project: Project, layerId: string, effect: LayerEffect): Project { + return withUpdatedAt( + project, + project.layers.map((l) => { + if (l.id !== layerId) return l; + const idx = l.effects.findIndex((e) => e.kind === effect.kind); + const effects = idx >= 0 ? l.effects.map((e, i) => (i === idx ? effect : e)) : [...l.effects, effect]; + return { ...l, effects }; + }), + ); +} + +export function removeLayerEffect(project: Project, layerId: string, kind: LayerEffect["kind"]): Project { + return withUpdatedAt( + project, + project.layers.map((l) => (l.id === layerId ? { ...l, effects: l.effects.filter((e) => e.kind !== kind) } : l)), + ); +} + +export function setLayerVisible(project: Project, layerId: string, visible: boolean): Project { + return withUpdatedAt( + project, + project.layers.map((l) => (l.id === layerId ? { ...l, visible } : l)), + ); +} + +export function setEffectEnabled(project: Project, layerId: string, kind: LayerEffect["kind"], enabled: boolean): Project { + return withUpdatedAt( + project, + project.layers.map((l) => + l.id === layerId + ? { ...l, effects: l.effects.map((e) => (e.kind === kind ? { ...e, enabled } : e)) } + : l, + ), + ); +} diff --git a/packages/editor-core/src/normalize.ts b/packages/editor-core/src/normalize.ts new file mode 100644 index 0000000..0a03564 --- /dev/null +++ b/packages/editor-core/src/normalize.ts @@ -0,0 +1,22 @@ +import type { Project } from "@pien-studio/types"; +import { normalizeEffect } from "./effects/registry"; + +export function normalizeProject(project: Project): Project { + return { + ...project, + canvas: { + width: Math.max(1, Math.round(project.canvas.width)), + height: Math.max(1, Math.round(project.canvas.height)), + unit: project.canvas.unit, + }, + layers: project.layers.map((layer) => ({ + ...layer, + width: layer.width !== undefined ? Math.max(1, Math.round(layer.width)) : undefined, + height: layer.height !== undefined ? Math.max(1, Math.round(layer.height)) : undefined, + scale: Number.isFinite(layer.scale) ? layer.scale : 1, + rotation: Number.isFinite(layer.rotation) ? layer.rotation : 0, + opacity: Number.isFinite(layer.opacity) ? Math.max(0, Math.min(1, layer.opacity)) : 1, + effects: Array.isArray(layer.effects) ? layer.effects.map(normalizeEffect) : [], + })), + }; +} diff --git a/packages/editor-core/src/project.ts b/packages/editor-core/src/project.ts new file mode 100644 index 0000000..234ae3c --- /dev/null +++ b/packages/editor-core/src/project.ts @@ -0,0 +1,38 @@ +import { PRESET_CANVAS_SIZES, type AspectRatio, type Project } from "@pien-studio/types"; + +const DEFAULT_ASPECT: AspectRatio = "4:5"; + +function defaultCanvas(aspect: AspectRatio) { + const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1]; + return { width: preset.width, height: preset.height, unit: "px" as const }; +} + +export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + title, + createdAt: now, + updatedAt: now, + canvas: defaultCanvas(aspect), + aspectRatio: aspect, + layers: [], + }; +} + +export function setCanvasSize( + project: Project, + width: number, + height: number, + unit: "px" | "in" | "cm" = "px", +): Project { + return { + ...project, + canvas: { + width: Math.max(1, Math.round(width)), + height: Math.max(1, Math.round(height)), + unit, + }, + updatedAt: new Date().toISOString(), + }; +} diff --git a/packages/editor-core/src/serialization.ts b/packages/editor-core/src/serialization.ts new file mode 100644 index 0000000..bbb59f8 --- /dev/null +++ b/packages/editor-core/src/serialization.ts @@ -0,0 +1,26 @@ +import { ProjectFileSchema, type Project, type ProjectFile } from "@pien-studio/types"; +import { normalizeProject } from "./normalize"; + +export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string { + const document: ProjectFile = { + format: "pien.project", + version: 1, + exportedAt: new Date().toISOString(), + app: { name: "pien.studio", platform: "web" }, + project, + assets: [], + history: { checkpointCount: options?.checkpointCount ?? 0 }, + }; + return JSON.stringify(document, null, 2); +} + +export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } { + try { + const data = JSON.parse(raw) as unknown; + const result = ProjectFileSchema.safeParse(data); + if (result.success) return { ok: true, project: normalizeProject(result.data.project) }; + return { ok: false, error: "Invalid project format" }; + } catch { + return { ok: false, error: "Invalid JSON" }; + } +} diff --git a/packages/editor-core/src/tools/registry.ts b/packages/editor-core/src/tools/registry.ts new file mode 100644 index 0000000..8a12f98 --- /dev/null +++ b/packages/editor-core/src/tools/registry.ts @@ -0,0 +1,65 @@ +export type ToolInteractionMode = + | "select" // can select and drag layers + | "pan" // pans the viewport, no layer interaction + | "paint" // pixel-level tool, fires onLayerClick with canvas coords + | "annotate"; // select-only, no drag (eg. face-blur region picking) + +export type ToolDefinition = { + id: string; + interactionMode: ToolInteractionMode; + allowsLayerDrag: boolean; + allowsLayerResize: boolean; + allowsLayerRotate: boolean; +}; + +const definitions: ToolDefinition[] = [ + { + id: "pointer", + interactionMode: "select", + allowsLayerDrag: true, + allowsLayerResize: true, + allowsLayerRotate: true, + }, + { + id: "hand", + interactionMode: "pan", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + }, + { + id: "face", + interactionMode: "annotate", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + }, + { + id: "fill", + interactionMode: "paint", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + }, + { + id: "brush", + interactionMode: "paint", + allowsLayerDrag: false, + allowsLayerResize: false, + allowsLayerRotate: false, + }, +]; + +const toolRegistry = new Map( + definitions.map((def) => [def.id, def]), +); + +export function getToolDefinition(id: string): ToolDefinition | undefined { + return toolRegistry.get(id); +} + +export function getAllTools(): ToolDefinition[] { + return definitions; +} + +export type EditorToolId = (typeof definitions)[number]["id"]; diff --git a/packages/storage/src/index.test.ts b/packages/storage/src/index.test.ts index 6bc031a..09bde37 100644 --- a/packages/storage/src/index.test.ts +++ b/packages/storage/src/index.test.ts @@ -72,7 +72,7 @@ function makeMalformedProject(id: string): Project { layers: [ { id: "layer-1", - type: "image", + type: "raster", x: 10, y: 10, width: 10.4, @@ -80,24 +80,25 @@ function makeMalformedProject(id: string): Project { scale: 0, rotation: 720.4, opacity: 0.75, + effects: [], }, ], }; } -function makeLegacyFaceBlurProject(id: string): Project { +function makeFaceBlurProject(id: string): Project { const now = new Date().toISOString(); return { id, - title: "legacy", + title: "faceblur", createdAt: now, updatedAt: now, aspectRatio: "4:5", canvas: { width: 1200, height: 1500, unit: "px" }, layers: [ { - id: "image-legacy", - type: "image", + id: "image-faceblur", + type: "raster", x: 30, y: 40, width: 600, @@ -105,11 +106,16 @@ function makeLegacyFaceBlurProject(id: string): Project { scale: 1, rotation: 0, opacity: 1, - faceBlur: { - method: "gaussian", - amount: 14, - regions: [{ x: 120, y: 160, width: 180, height: 200 }], - }, + visible: true, + effects: [ + { + kind: "face-blur", + enabled: true, + method: "gaussian", + amount: 14, + regions: [{ x: 120, y: 160, width: 180, height: 200 }], + }, + ], }, ], }; @@ -148,19 +154,21 @@ describe("storage read flows", () => { expect(raw?.layers[0]?.height).toBe(11.6); }); - it("loads legacy projects without source dimensions through storage and normalize flow", async () => { - const source = makeLegacyFaceBlurProject("project-legacy-faceblur"); + it("loads projects with face-blur effect through storage and normalize flow", async () => { + const source = makeFaceBlurProject("project-faceblur"); await seedProject(source); const loaded = await getProjectById(source.id); - const region = loaded?.layers[0]?.faceBlur?.regions[0]; - expect(region).toBeDefined(); + const effect = loaded?.layers[0]?.effects.find((e) => e.kind === "face-blur"); + expect(effect).toBeDefined(); + const region = effect?.kind === "face-blur" ? effect.regions[0] : undefined; expect(region?.x).toBe(120); expect(region?.sourceWidth).toBeUndefined(); expect(region?.sourceHeight).toBeUndefined(); const listed = await loadProjects(); - const listedRegion = listed[0]?.layers[0]?.faceBlur?.regions[0]; + const listedEffect = listed.find((p) => p.id === source.id)?.layers[0]?.effects.find((e) => e.kind === "face-blur"); + const listedRegion = listedEffect?.kind === "face-blur" ? listedEffect.regions[0] : undefined; expect(listedRegion?.width).toBe(180); expect(listedRegion?.sourceWidth).toBeUndefined(); expect(listedRegion?.sourceHeight).toBeUndefined(); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 09c6eaa..00a5615 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -100,7 +100,7 @@ function makeLinkId(projectId: string, layerId: string): string { } function isBinaryLayer(layer: Layer): boolean { - return layer.type === "image" || layer.type === "sticker"; + return layer.type === "raster" || layer.type === "sticker"; } function inferMimeType(layer: Layer): string { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 51878f2..21d7102 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const LayerTypeSchema = z.enum(["image", "text", "sticker"]); +export const LayerTypeSchema = z.enum(["raster", "text", "sticker"]); export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]); @@ -14,20 +14,25 @@ export const FaceBlurRegionSchema = z.object({ censorColor: z.string().optional(), }); -export const FaceBlurSettingsSchema = z.object({ +export const FaceBlurEffectSchema = z.object({ + kind: z.literal("face-blur"), + enabled: z.boolean().default(true), method: FaceBlurMethodSchema, amount: z.number().int().min(4).max(40), regions: z.array(FaceBlurRegionSchema), censorColor: z.string().optional(), }); +export const LayerEffectSchema = FaceBlurEffectSchema; + export const LayerSchema = z.object({ id: z.string(), type: LayerTypeSchema, name: z.string().optional(), assetId: z.string().optional(), sourceUri: z.string().optional(), - faceBlur: FaceBlurSettingsSchema.optional(), + effects: z.array(LayerEffectSchema).default([]), + visible: z.boolean().default(true), x: z.number(), y: z.number(), width: z.number().optional(), @@ -116,7 +121,10 @@ export const ProjectFileSchema = ProjectFileV1Schema; export type Project = z.infer; export type Layer = z.infer; export type LayerType = z.infer; +export type LayerEffect = z.infer; export type FaceBlurMethod = z.infer; export type FaceBlurRegion = z.infer; -export type FaceBlurSettings = z.infer; +export type FaceBlurEffect = z.infer; +// Kept for compatibility with existing renderer code +export type FaceBlurSettings = Omit; export type ProjectFile = z.infer;