feat: layer effects, bucket fill, simple brush

This commit is contained in:
2026-05-21 00:02:12 +07:00
parent bcf4650c70
commit e7dd9420e7
47 changed files with 1675 additions and 1038 deletions
+92 -4
View File
@@ -2,7 +2,7 @@
import React from "react"; import React from "react";
import { useParams } from "next/navigation"; 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 { useEditorStore } from "../../../store/editor-store";
import { useUiStore, isDarkTheme } from "../../../store/ui-store"; import { useUiStore, isDarkTheme } from "../../../store/ui-store";
import { CanvasSizeModal } from "../../../components/canvas-size-modal"; 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 { EditorSidebar } from "../../../components/editor/editor-sidebar";
import { ToolRail } from "../../../components/editor/tool-rail"; import { ToolRail } from "../../../components/editor/tool-rail";
import { exportProjectAsPng } from "../../../lib/export-png"; 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 { createEditorToolControllers } from "../../../lib/editor-tool-controller";
import { useFaceDetection } from "../../../hooks/use-face-detection"; import { useFaceDetection } from "../../../hooks/use-face-detection";
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow"; import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
@@ -29,6 +31,8 @@ const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> =
pointer: MousePointer2, pointer: MousePointer2,
hand: Hand, hand: Hand,
face: ScanFace, face: ScanFace,
fill: PaintBucket,
brush: Brush,
"add-text": Type, "add-text": Type,
"import-image": ImagePlus, "import-image": ImagePlus,
}; };
@@ -64,8 +68,13 @@ export default function EditorPage() {
removeSelectedLayer, removeSelectedLayer,
addLayerByType, addLayerByType,
moveSelectedLayerOrder, moveSelectedLayerOrder,
addCanvasSizedLayer,
importImageFromFile, importImageFromFile,
setImageLayerFaceBlur, updateImageLayerSource,
setLayerEffect,
removeLayerEffect,
setLayerVisible,
setEffectEnabled,
setCanvasSize, setCanvasSize,
setTool, setTool,
undo, undo,
@@ -83,11 +92,17 @@ export default function EditorPage() {
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu(); const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false); const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = 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 previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
const imageInputRef = React.useRef<HTMLInputElement | null>(null); const imageInputRef = React.useRef<HTMLInputElement | null>(null);
const selectedImageLayer = React.useMemo(() => { const selectedImageLayer = React.useMemo(() => {
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) { if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) {
return null; return null;
} }
return { return {
@@ -126,7 +141,8 @@ export default function EditorPage() {
selectedLayer, selectedLayer,
faceDetectionsLayerId, faceDetectionsLayerId,
faceDetections, faceDetections,
setImageLayerFaceBlur, setLayerEffect,
removeLayerEffect,
}); });
React.useEffect(() => { React.useEffect(() => {
@@ -174,6 +190,8 @@ export default function EditorPage() {
useEditorShortcuts({ useEditorShortcuts({
onSave: handleSave, onSave: handleSave,
onUndo: undo,
onRedo: redo,
onCopy: copySelectedLayer, onCopy: copySelectedLayer,
onCut: cutSelectedLayer, onCut: cutSelectedLayer,
onPaste: pasteLayer, onPaste: pasteLayer,
@@ -204,6 +222,55 @@ export default function EditorPage() {
setCanvasSize(width, height); 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( const toolControllers = React.useMemo(
() => () =>
createEditorToolControllers({ createEditorToolControllers({
@@ -213,6 +280,8 @@ export default function EditorPage() {
pointer: t("editor.toolPointer"), pointer: t("editor.toolPointer"),
pan: t("editor.toolPan"), pan: t("editor.toolPan"),
face: t("editor.toolFace"), face: t("editor.toolFace"),
fill: t("editor.toolFill"),
brush: t("editor.toolBrush"),
text: t("editor.toolText"), text: t("editor.toolText"),
image: t("editor.toolImage"), image: t("editor.toolImage"),
}, },
@@ -295,11 +364,28 @@ export default function EditorPage() {
onCopy={copySelectedLayer} onCopy={copySelectedLayer}
onCut={cutSelectedLayer} onCut={cutSelectedLayer}
onPaste={pasteLayer} onPaste={pasteLayer}
onFillLayer={handleFillLayer}
brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }}
onBrushCommit={handleBrushCommit}
/> />
<EditorSidebar <EditorSidebar
isDark={isDark} isDark={isDark}
tool={tool} tool={tool}
fillColor={fillColor}
fillTolerance={fillTolerance}
onSetFillColor={setFillColor}
onSetFillTolerance={setFillTolerance}
onCreateFillLayer={handleCreateFillLayer}
brushColor={brushColor}
brushSize={brushSize}
brushOpacity={brushOpacity}
brushHardness={brushHardness}
onSetBrushColor={setBrushColor}
onSetBrushSize={setBrushSize}
onSetBrushOpacity={setBrushOpacity}
onSetBrushHardness={setBrushHardness}
onAddLayer={handleAddLayer}
layers={project.layers} layers={project.layers}
selectedLayerId={selectedLayerId} selectedLayerId={selectedLayerId}
selectedLayer={selectedLayer} selectedLayer={selectedLayer}
@@ -314,6 +400,8 @@ export default function EditorPage() {
censorColor={censorColor} censorColor={censorColor}
selectedFaceIndices={selectedFaceIndices} selectedFaceIndices={selectedFaceIndices}
onSelectLayer={selectLayer} onSelectLayer={selectLayer}
onSetLayerVisible={setLayerVisible}
onSetEffectEnabled={(layerId, kind, enabled) => setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)}
onMoveLayerOrder={moveSelectedLayerOrder} onMoveLayerOrder={moveSelectedLayerOrder}
onRemoveSelectedLayer={removeSelectedLayer} onRemoveSelectedLayer={removeSelectedLayer}
onUndo={undo} onUndo={undo}
+1 -1
View File
@@ -92,7 +92,7 @@ export default function HomePage() {
const base = createProject(title, "free"); const base = createProject(title, "free");
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height); const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
const project = addLayer(projectWithImageCanvas, createLayer("image", { const project = addLayer(projectWithImageCanvas, createLayer("raster", {
name: file.name, name: file.name,
sourceUri, sourceUri,
x: 0, x: 0,
+3 -1
View File
@@ -26,7 +26,7 @@ describe("CanvasRenderer", () => {
it("keeps the rotation handle interactive", () => { it("keeps the rotation handle interactive", () => {
const layer: Layer = { const layer: Layer = {
id: "layer-1", id: "layer-1",
type: "image", type: "raster",
sourceUri: "data:image/png;base64,test", sourceUri: "data:image/png;base64,test",
x: 0, x: 0,
y: 0, y: 0,
@@ -35,6 +35,8 @@ describe("CanvasRenderer", () => {
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
}; };
const onRotateLayer = vi.fn(); const onRotateLayer = vi.fn();
+89 -37
View File
@@ -8,10 +8,13 @@ import {
CANVAS_ROTATE_HANDLE_BASE_SIZE, CANVAS_ROTATE_HANDLE_BASE_SIZE,
} from "../lib/editor-constants"; } from "../lib/editor-constants";
import { buildFaceLabelOverlays } from "../lib/canvas-geometry"; 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 { useCanvasInteractions } from "../hooks/use-canvas-interactions";
import { useTranslations } from "../hooks/use-translations"; 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 { interface CanvasRendererProps {
layers: Layer[]; layers: Layer[];
@@ -29,7 +32,10 @@ interface CanvasRendererProps {
onInteractionEnd?: () => void; onInteractionEnd?: () => void;
onContextMenu?: (x: number, y: number) => void; onContextMenu?: (x: number, y: number) => void;
isDark: boolean; 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?: { faceDetections?: {
x: number; x: number;
y: number; y: number;
@@ -40,49 +46,57 @@ interface CanvasRendererProps {
faceOverlayLayerId?: string | null; faceOverlayLayerId?: string | null;
faceBlurPreview?: { faceBlurPreview?: {
layerId: string; layerId: string;
method: FaceBlurMethod; effects: LayerEffect[];
amount: number;
regions: { x: number; y: number; width: number; height: number }[];
} | null; } | null;
} }
function BlurredImageLayer({ function BrushOverlayCanvas({ stroke, width, height }: { stroke: HTMLCanvasElement; width: number; height: number }) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(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 (
<canvas
ref={canvasRef}
width={Math.max(1, Math.round(width))}
height={Math.max(1, Math.round(height))}
className="pointer-events-none absolute inset-0 h-full w-full rounded"
/>
);
}
function EffectImageLayer({
layer, layer,
width, width,
height, height,
faceBlurOverride, effectsOverride,
}: { }: {
layer: Layer; layer: Layer;
width: number; width: number;
height: number; height: number;
faceBlurOverride?: { effectsOverride?: LayerEffect[] | null;
method: FaceBlurMethod;
amount: number;
regions: {
x: number;
y: number;
width: number;
height: number;
censorColor?: string;
}[];
censorColor?: string;
} | null;
}) { }) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null); const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const imageRef = React.useRef<HTMLImageElement | null>(null); const imageRef = React.useRef<HTMLImageElement | null>(null);
const activeEffects = effectsOverride ?? layer.effects;
const draw = React.useCallback(() => { const draw = React.useCallback(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const image = imageRef.current; const image = imageRef.current;
if (!canvas || !image) return; if (!canvas || !image) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
const cw = canvas.width; ctx.clearRect(0, 0, canvas.width, canvas.height);
const ch = canvas.height; renderLayerWithEffects(ctx, image, activeEffects, canvas.width, canvas.height);
ctx.clearRect(0, 0, cw, ch); }, [activeEffects]);
const blur = faceBlurOverride ?? layer.faceBlur;
renderImageWithFaceBlur(ctx, image, blur, cw, ch);
}, [faceBlurOverride, layer.faceBlur]);
React.useEffect(() => { React.useEffect(() => {
if (!layer.sourceUri) return; if (!layer.sourceUri) return;
@@ -129,6 +143,9 @@ export function CanvasRenderer({
onInteractionStart, onInteractionStart,
onInteractionEnd, onInteractionEnd,
onContextMenu, onContextMenu,
onFillLayer,
brushOptions,
onBrushCommit,
isDark, isDark,
tool = "pointer", tool = "pointer",
faceDetections = [], faceDetections = [],
@@ -136,6 +153,36 @@ export function CanvasRenderer({
faceBlurPreview = null, faceBlurPreview = null,
}: CanvasRendererProps) { }: CanvasRendererProps) {
const { t } = useTranslations(); const { t } = useTranslations();
const brushStrokeRef = React.useRef<BrushStroke | null>(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 { const {
containerRef, containerRef,
viewport, viewport,
@@ -164,6 +211,10 @@ export function CanvasRenderer({
onInteractionStart, onInteractionStart,
onInteractionEnd, onInteractionEnd,
onContextMenu, onContextMenu,
onFillLayer,
onBrushStrokeStart: handleBrushStrokeStart,
onBrushStrokeMove: handleBrushStrokeMove,
onBrushStrokeEnd: handleBrushStrokeEnd,
}); });
const faceLabelOverlays = React.useMemo(() => { const faceLabelOverlays = React.useMemo(() => {
@@ -194,10 +245,10 @@ export function CanvasRenderer({
height: "100%", height: "100%",
touchAction: "none", touchAction: "none",
userSelect: "none", userSelect: "none",
cursor: tool === "hand" || isSpacePan ? "grab" : "default", cursor: isSpacePan ? "grab" : (getToolUiDefinition(tool)?.cursor ?? "default"),
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
if (tool === "pointer" && e.button === 0) onSelectLayer(null); if (getToolDefinition(tool)?.interactionMode === "select" && e.button === 0) onSelectLayer(null);
onContainerPointerDown(e); onContainerPointerDown(e);
}} }}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
@@ -232,7 +283,7 @@ export function CanvasRenderer({
> >
{layers.map((layer, idx) => { {layers.map((layer, idx) => {
const isSelected = layer.id === selectedLayerId; const isSelected = layer.id === selectedLayerId;
const isImage = layer.type === "image"; const isImage = layer.type === "raster";
const layerWidth = const layerWidth =
layer.width ?? layer.width ??
(isImage ? Math.round(200 * layer.scale) : undefined); (isImage ? Math.round(200 * layer.scale) : undefined);
@@ -256,7 +307,8 @@ export function CanvasRenderer({
height: layerHeight, height: layerHeight,
transform: `rotate(${layer.rotation}deg)`, transform: `rotate(${layer.rotation}deg)`,
opacity: layer.opacity, opacity: layer.opacity,
cursor: "move", display: layer.visible === false ? "none" : undefined,
cursor: getToolUiDefinition(tool)?.cursor ?? "move",
border: isSelected border: isSelected
? "2px solid var(--color-accent-strong)" ? "2px solid var(--color-accent-strong)"
: "1px dashed transparent", : "1px dashed transparent",
@@ -270,17 +322,14 @@ export function CanvasRenderer({
onClick={() => onSelectLayer(layer.id)} onClick={() => onSelectLayer(layer.id)}
> >
{isImage && layer.sourceUri ? ( {isImage && layer.sourceUri ? (
(faceBlurPreview && layer.effects.length > 0 || (faceBlurPreview && faceBlurPreview.layerId === layer.id) ? (
faceBlurPreview.layerId === layer.id && <EffectImageLayer
faceBlurPreview.regions.length > 0) ||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
<BlurredImageLayer
layer={layer} layer={layer}
width={layerWidth ?? 1} width={layerWidth ?? 1}
height={layerHeight ?? 1} height={layerHeight ?? 1}
faceBlurOverride={ effectsOverride={
faceBlurPreview && faceBlurPreview.layerId === layer.id faceBlurPreview && faceBlurPreview.layerId === layer.id
? faceBlurPreview ? faceBlurPreview.effects
: null : null
} }
/> />
@@ -308,6 +357,9 @@ export function CanvasRenderer({
{layer.type} {layer.type}
</div> </div>
)} )}
{brushOverlay && brushOverlay.layerId === layer.id ? (
<BrushOverlayCanvas stroke={brushOverlay.canvas} width={layerWidth ?? 1} height={layerHeight ?? 1} />
) : null}
{tool === "face" && faceOverlayLayerId === layer.id {tool === "face" && faceOverlayLayerId === layer.id
? faceDetections.map((face, index) => ( ? faceDetections.map((face, index) => (
<div <div
@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import type { FaceBlurMethod, Layer } from "@pien-studio/types"; import type { Layer, LayerEffect } from "@pien-studio/types";
import type { FaceDetectionOverlay } from "../../hooks/use-face-detection"; import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
import { CanvasRenderer } from "../canvas-renderer"; import { CanvasRenderer } from "../canvas-renderer";
import { CanvasContextMenu } from "./canvas-context-menu"; import { CanvasContextMenu } from "./canvas-context-menu";
import type { BrushOptions, BrushStroke } from "../../lib/brush-painter";
type EditorCanvasStageProps = { type EditorCanvasStageProps = {
isDark: boolean; isDark: boolean;
@@ -10,15 +11,13 @@ type EditorCanvasStageProps = {
canvasWidth: number; canvasWidth: number;
canvasHeight: number; canvasHeight: number;
selectedLayerId: string | null; selectedLayerId: string | null;
tool: "pointer" | "hand" | "face"; tool: string;
onFillLayer?: (layerId: string, x: number, y: number) => void;
brushOptions?: BrushOptions;
onBrushCommit?: (layerId: string, stroke: BrushStroke) => void;
faceDetections: FaceDetectionOverlay[]; faceDetections: FaceDetectionOverlay[];
faceOverlayLayerId: string | null; faceOverlayLayerId: string | null;
faceBlurPreview: { faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null;
layerId: string;
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number }[];
} | null;
contextMenu: { x: number; y: number } | null; contextMenu: { x: number; y: number } | null;
labels: { copy: string; cut: string; paste: string }; labels: { copy: string; cut: string; paste: string };
onSelectLayer: (id: string | null) => void; onSelectLayer: (id: string | null) => void;
@@ -64,6 +63,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
onCopy, onCopy,
onCut, onCut,
onPaste, onPaste,
onFillLayer,
brushOptions,
onBrushCommit,
} = props; } = props;
return ( return (
@@ -93,6 +95,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
onInteractionStart={onInteractionStart} onInteractionStart={onInteractionStart}
onInteractionEnd={onInteractionEnd} onInteractionEnd={onInteractionEnd}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
onFillLayer={onFillLayer}
brushOptions={brushOptions}
onBrushCommit={onBrushCommit}
isDark={isDark} isDark={isDark}
tool={tool} tool={tool}
faceDetections={faceDetections} faceDetections={faceDetections}
@@ -17,7 +17,7 @@ describe("EditorMobileSection", () => {
layers: [], layers: [],
selectedLayerId: null, selectedLayerId: null,
tool: "face", 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, faceOverlayLayerId: null,
faceStatus: "idle", faceStatus: "idle",
faceBlurPreview: null, faceBlurPreview: null,
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { CanvasRenderer } from "../canvas-renderer"; 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"; import type { FaceDetectionOverlay } from "../../hooks/use-face-detection";
type EditorMobileSectionProps = { type EditorMobileSectionProps = {
@@ -9,16 +9,11 @@ type EditorMobileSectionProps = {
canvasHeight: number; canvasHeight: number;
layers: Layer[]; layers: Layer[];
selectedLayerId: string | null; selectedLayerId: string | null;
tool: "pointer" | "hand" | "face"; tool: string;
faceDetections: FaceDetectionOverlay[]; faceDetections: FaceDetectionOverlay[];
faceOverlayLayerId: string | null; faceOverlayLayerId: string | null;
faceStatus: "idle" | "detecting" | "unsupported"; faceStatus: "idle" | "detecting" | "unsupported";
faceBlurPreview: { faceBlurPreview: { layerId: string; effects: LayerEffect[] } | null;
layerId: string;
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number }[];
} | null;
labels: { labels: {
resize: string; resize: string;
faceMlFailedShort: string; faceMlFailedShort: string;
+110 -2
View File
@@ -7,7 +7,21 @@ import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-det
type EditorSidebarProps = { type EditorSidebarProps = {
isDark: boolean; 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[]; layers: Layer[];
selectedLayerId: string | null; selectedLayerId: string | null;
selectedLayer: Layer | null; selectedLayer: Layer | null;
@@ -22,6 +36,8 @@ type EditorSidebarProps = {
censorColor: string; censorColor: string;
selectedFaceIndices: number[]; selectedFaceIndices: number[];
onSelectLayer: (layerId: string | null) => void; 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; onMoveLayerOrder: (direction: "up" | "down") => void;
onRemoveSelectedLayer: () => void; onRemoveSelectedLayer: () => void;
onUndo: () => void; onUndo: () => void;
@@ -39,6 +55,20 @@ type EditorSidebarProps = {
export function EditorSidebar({ export function EditorSidebar({
isDark, isDark,
tool, tool,
fillColor,
fillTolerance,
onSetFillColor,
onSetFillTolerance,
onCreateFillLayer,
brushColor,
brushSize,
brushOpacity,
brushHardness,
onSetBrushColor,
onSetBrushSize,
onSetBrushOpacity,
onSetBrushHardness,
onAddLayer,
layers, layers,
selectedLayerId, selectedLayerId,
selectedLayer, selectedLayer,
@@ -53,6 +83,8 @@ export function EditorSidebar({
censorColor, censorColor,
selectedFaceIndices, selectedFaceIndices,
onSelectLayer, onSelectLayer,
onSetLayerVisible,
onSetEffectEnabled,
onMoveLayerOrder, onMoveLayerOrder,
onRemoveSelectedLayer, onRemoveSelectedLayer,
onUndo, onUndo,
@@ -74,10 +106,86 @@ export function EditorSidebar({
selectedLayerId={selectedLayerId} selectedLayerId={selectedLayerId}
isDark={isDark} isDark={isDark}
onSelectLayer={(layerId) => onSelectLayer(layerId)} onSelectLayer={(layerId) => onSelectLayer(layerId)}
onSetLayerVisible={onSetLayerVisible}
onSetEffectEnabled={onSetEffectEnabled}
onMoveLayerOrder={onMoveLayerOrder} onMoveLayerOrder={onMoveLayerOrder}
onRemoveSelectedLayer={onRemoveSelectedLayer} onRemoveSelectedLayer={onRemoveSelectedLayer}
onAddLayer={onAddLayer}
/> />
{tool === "fill" && onSetFillColor && onSetFillTolerance ? (
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
Fill
</p>
<div className="flex items-center gap-2 mb-3">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
<input
type="color"
value={fillColor ?? "#ff0000"}
onChange={(e) => onSetFillColor(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
/>
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{fillColor ?? "#ff0000"}</span>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Tolerance</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{fillTolerance ?? 32}</span>
</div>
<input
type="range"
min={0}
max={255}
value={fillTolerance ?? 32}
onChange={(e) => onSetFillTolerance(Number(e.target.value))}
className="w-full accent-[var(--color-accent-strong)]"
/>
</div>
</div>
) : null}
{tool === "brush" && onSetBrushColor && onSetBrushSize && onSetBrushOpacity && onSetBrushHardness ? (
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
Brush
</p>
<div className="flex items-center gap-2 mb-3">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
<input
type="color"
value={brushColor ?? "#000000"}
onChange={(e) => onSetBrushColor(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
/>
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{brushColor ?? "#000000"}</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Size</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{brushSize ?? 20}px</span>
</div>
<input type="range" min={1} max={200} value={brushSize ?? 20} onChange={(e) => onSetBrushSize(Number(e.target.value))} className="w-full accent-[var(--color-accent-strong)]" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Opacity</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushOpacity ?? 1) * 100)}%</span>
</div>
<input type="range" min={0} max={100} value={Math.round((brushOpacity ?? 1) * 100)} onChange={(e) => onSetBrushOpacity(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Hardness</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushHardness ?? 0.8) * 100)}%</span>
</div>
<input type="range" min={0} max={100} value={Math.round((brushHardness ?? 0.8) * 100)} onChange={(e) => onSetBrushHardness(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
</div>
</div>
</div>
) : null}
{tool === "face" ? ( {tool === "face" ? (
<FacePanel <FacePanel
isDark={isDark} isDark={isDark}
@@ -89,7 +197,7 @@ export function EditorSidebar({
blurAmount={blurAmount} blurAmount={blurAmount}
censorColor={censorColor} censorColor={censorColor}
selectedFaceIndices={selectedFaceIndices} selectedFaceIndices={selectedFaceIndices}
hasActiveBlur={Boolean(selectedLayer && selectedLayer.type === "image" && selectedLayer.faceBlur)} hasActiveBlur={Boolean(selectedLayer && selectedLayer.effects.some((e) => e.kind === "face-blur"))}
onSetBlurMethod={onSetBlurMethod} onSetBlurMethod={onSetBlurMethod}
onSetBlurAmount={onSetBlurAmount} onSetBlurAmount={onSetBlurAmount}
onSetCensorColor={onSetCensorColor} onSetCensorColor={onSetCensorColor}
+1 -1
View File
@@ -57,7 +57,7 @@ export function FacePanel({
? t("editor.faceMlFailed") ? t("editor.faceMlFailed")
: faceStatus === "detecting" : faceStatus === "detecting"
? t("editor.detectingFaces") ? t("editor.detectingFaces")
: selectedLayer?.type !== "image" : selectedLayer?.type !== "raster"
? t("editor.selectImageLayer") ? t("editor.selectImageLayer")
: faceDetections.length === 0 : faceDetections.length === 0
? t("editor.noFacesFound") ? t("editor.noFacesFound")
+99 -22
View File
@@ -2,69 +2,146 @@
import type { Layer } from "@pien-studio/types"; import type { Layer } from "@pien-studio/types";
import Image from "next/image"; import Image from "next/image";
import { Eye, EyeOff } from "lucide-react";
import { useTranslations } from "../../hooks/use-translations"; import { useTranslations } from "../../hooks/use-translations";
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme"; import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
const EFFECT_LABELS: Record<string, string> = {
"face-blur": "Face Blur",
};
type Props = { type Props = {
layers: Layer[]; layers: Layer[];
selectedLayerId: string | null; selectedLayerId: string | null;
isDark: boolean; isDark: boolean;
onSelectLayer: (layerId: string) => void; onSelectLayer: (layerId: string) => void;
onSetLayerVisible: (layerId: string, visible: boolean) => void;
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void;
onMoveLayerOrder: (direction: "up" | "down") => void; onMoveLayerOrder: (direction: "up" | "down") => void;
onRemoveSelectedLayer: () => 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(); const { t } = useTranslations();
return ( return (
<div className={panelClass(isDark)}> <div className={panelClass(isDark)}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2> <h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
<div className="flex items-center gap-1.5">
{onAddLayer ? (
<button
type="button"
onClick={onAddLayer}
title={t("editor.newLayer")}
className={`rounded border px-1.5 py-0.5 text-[10px] font-semibold ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}
>
+ New
</button>
) : null}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}> <span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
{layers.length} {layers.length}
</span> </span>
</div> </div>
<div className={`mt-3 max-h-[320px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}> </div>
<div className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
{layers.slice().reverse().map((layer, idx) => { {layers.slice().reverse().map((layer, idx) => {
const isSelected = layer.id === selectedLayerId; 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 ( return (
<button <div key={layer.id}>
key={layer.id} <div className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
type="button"
onClick={() => onSelectLayer(layer.id)}
className={`group flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition ${
isSelected isSelected
? "bg-[var(--color-accent-strong)] text-white" ? "bg-[var(--color-accent-strong)] text-white"
: isDark : isDark
? "text-[#d7dae0] hover:bg-white/10" ? "text-[#d7dae0] hover:bg-white/10"
: "text-[#1f2430] hover:bg-black/5" : "text-[#1f2430] hover:bg-black/5"
}`} } ${isHidden ? "opacity-40" : ""}`}>
{/* Thumbnail */}
<button
type="button"
onClick={() => onSelectLayer(layer.id)}
className="shrink-0"
> >
<div className="flex items-center gap-2"> <div className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}>
<span className={`w-6 text-[10px] font-semibold ${isSelected ? "text-white/90" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>{idx + 1}</span> {isRaster && layer.sourceUri ? (
<div className={`h-9 w-9 shrink-0 overflow-hidden rounded border ${isSelected ? "border-white/60 bg-white/10" : isDark ? "border-white/15 bg-[#1f2126]" : "border-black/10 bg-[#eef1f6]"}`}> <Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={40} height={40} unoptimized className="h-full w-full object-cover" draggable={false} />
{isImageLayer && layer.sourceUri ? (
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={36} height={36} unoptimized className="h-full w-full object-cover" draggable={false} />
) : ( ) : (
<div className={`flex h-full w-full items-center justify-center text-[9px] font-semibold uppercase tracking-wide ${isSelected ? "text-white/90" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}> <div className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
{layer.type} {layer.type}
</div> </div>
)} )}
</div> </div>
<div> </button>
<p className="font-semibold">{layer.name ?? layer.type}</p>
<p className={`${isSelected ? "text-white/80" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>{layer.type}</p> {/* Info */}
<button type="button" onClick={() => onSelectLayer(layer.id)} className="min-w-0 flex-1 text-left">
<p className="truncate font-semibold leading-tight">{layer.name ?? layer.type}</p>
<p className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>
{layer.type}
{layer.opacity < 1 ? ` · ${Math.round(layer.opacity * 100)}%` : ""}
</p>
</button>
{/* Visibility toggle */}
<button
type="button"
title={isHidden ? "Show layer" : "Hide layer"}
onClick={(e) => { e.stopPropagation(); onSetLayerVisible(layer.id, !isHidden); }}
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
>
{isHidden
? <EyeOff className="h-3.5 w-3.5" />
: <Eye className="h-3.5 w-3.5" />}
</button>
{/* Layer index */}
<span className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
{layers.length - idx}
</span>
</div> </div>
</div>
<span className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : isDark ? "bg-[#3d424c]" : "bg-[#d4d8e0]"}`} /> {/* Effects chips */}
{hasEffects ? (
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
{layer.effects.map((effect) => {
const isDisabled = effect.enabled === false;
return (
<button
key={effect.kind}
type="button"
title={isDisabled ? "Enable effect" : "Disable effect"}
onClick={() => onSetEffectEnabled(layer.id, effect.kind, isDisabled)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
isDisabled
? isDark ? "bg-white/10 text-[#6b7280] line-through" : "bg-black/5 text-[#9ca3af] line-through"
: isSelected
? "bg-white/20 text-white"
: isDark
? "bg-[var(--color-accent-strong)]/20 text-[var(--color-accent-strong)]"
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
}`}
>
{isDisabled ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
{EFFECT_LABELS[effect.kind] ?? effect.kind}
</button> </button>
); );
})} })}
{layers.length === 0 ? <div className={`px-2 py-6 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.noLayersYet")}</div> : null}
</div> </div>
<div className="mt-3 flex gap-2"> ) : null}
</div>
);
})}
{layers.length === 0 ? (
<div className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
{t("editor.noLayersYet")}
</div>
) : null}
</div>
<div className="mt-2 flex gap-1.5">
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button> <button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
<button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button> <button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button>
<button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button> <button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button>
+58 -7
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import type { Layer } from "@pien-studio/types"; import type { Layer } from "@pien-studio/types";
import { getToolDefinition } from "@pien-studio/editor-core";
type Viewport = { type Viewport = {
x: number; x: number;
@@ -10,7 +11,7 @@ type Viewport = {
type UseCanvasInteractionsOptions = { type UseCanvasInteractionsOptions = {
canvasWidth: number; canvasWidth: number;
canvasHeight: number; canvasHeight: number;
tool: "pointer" | "hand" | "face"; tool: string;
onSelectLayer: (id: string | null) => void; onSelectLayer: (id: string | null) => void;
onMoveLayer: (id: string, x: number, y: number) => void; onMoveLayer: (id: string, x: number, y: number) => void;
onMoveLayerEnd?: (id: string, x: number, y: number) => void; onMoveLayerEnd?: (id: string, x: number, y: number) => void;
@@ -21,6 +22,10 @@ type UseCanvasInteractionsOptions = {
onInteractionStart?: () => void; onInteractionStart?: () => void;
onInteractionEnd?: () => void; onInteractionEnd?: () => void;
onContextMenu?: (x: number, y: number) => 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; const MIN_SCALE = 0.1;
@@ -43,6 +48,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onInteractionStart, onInteractionStart,
onInteractionEnd, onInteractionEnd,
onContextMenu, onContextMenu,
onFillLayer,
onBrushStrokeStart,
onBrushStrokeMove,
onBrushStrokeEnd,
} = options; } = options;
const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 }); const [viewport, setViewport] = React.useState<Viewport>({ x: 0, y: 0, scale: 1 });
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
@@ -86,6 +95,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
startRotation: number; startRotation: number;
lastRotation: number; lastRotation: number;
} | null>(null); } | null>(null);
const brushRef = React.useRef<{ id: string; lastX: number; lastY: number; rect: DOMRect } | null>(null);
const pinchRef = React.useRef<{ const pinchRef = React.useRef<{
active: boolean; active: boolean;
initialPinchPx: number; initialPinchPx: number;
@@ -237,10 +248,18 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
return; return;
} }
if (e.button !== 0) return; if (e.button !== 0) return;
if (tool !== "hand" && tool !== "face" && !isSpacePan) return; const toolDef = getToolDefinition(tool);
if (toolDef?.interactionMode !== "select" && !isSpacePan) {
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
isPanning.current = true; isPanning.current = true;
lastPos.current = { x: e.clientX, y: e.clientY }; 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<HTMLDivElement>) { function onContainerPointerMove(e: React.PointerEvent<HTMLDivElement>) {
@@ -251,7 +270,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy })); setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy }));
return; 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 { centerX, centerY, startAngle, startRotation, id } = rotateRef.current;
const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX); const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
const delta = currentAngle - startAngle; const delta = currentAngle - startAngle;
@@ -260,7 +289,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onRotateLayer(id, nextRotation); onRotateLayer(id, nextRotation);
return; return;
} }
if (tool === "pointer" && resizeRef.current && onResizeLayer) { if (toolDef?.allowsLayerResize && resizeRef.current && onResizeLayer) {
const dx = (e.clientX - resizeRef.current.startX) / viewport.scale; const dx = (e.clientX - resizeRef.current.startX) / viewport.scale;
const dy = (e.clientY - resizeRef.current.startY) / viewport.scale; const dy = (e.clientY - resizeRef.current.startY) / viewport.scale;
const corner = resizeRef.current.corner; const corner = resizeRef.current.corner;
@@ -315,7 +344,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}); });
return; return;
} }
if (tool === "pointer" && dragRef.current) { if (toolDef?.allowsLayerDrag && dragRef.current) {
const dx = (e.clientX - dragRef.current.startEventX) / viewport.scale; const dx = (e.clientX - dragRef.current.startEventX) / viewport.scale;
const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale; const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale;
const nextX = dragRef.current.startLayerX + dx; const nextX = dragRef.current.startLayerX + dx;
@@ -362,6 +391,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
const { id, lastRotation } = rotateRef.current; const { id, lastRotation } = rotateRef.current;
if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation); if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation);
} }
if (brushRef.current && onBrushStrokeEnd) {
onBrushStrokeEnd(brushRef.current.id);
brushRef.current = null;
}
dragRef.current = null; dragRef.current = null;
resizeRef.current = null; resizeRef.current = null;
resizeMovePendingRef.current = null; resizeMovePendingRef.current = null;
@@ -372,6 +405,24 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) { function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
if (isSpacePan) return; if (isSpacePan) return;
e.stopPropagation(); 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); e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { dragRef.current = {
id: layer.id, id: layer.id,
@@ -387,7 +438,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
} }
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) { function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
if (tool !== "pointer" || !onResizeLayer) return; if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return;
e.stopPropagation(); e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
rotateRef.current = null; rotateRef.current = null;
@@ -411,7 +462,7 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
} }
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) { function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
if (tool !== "pointer" || !onRotateLayer) return; if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return;
e.stopPropagation(); e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
resizeRef.current = null; resizeRef.current = null;
+6 -1
View File
@@ -32,8 +32,13 @@ export function useEditorBindings() {
removeSelectedLayer: s.removeSelectedLayer, removeSelectedLayer: s.removeSelectedLayer,
addLayerByType: s.addLayerByType, addLayerByType: s.addLayerByType,
moveSelectedLayerOrder: s.moveSelectedLayerOrder, moveSelectedLayerOrder: s.moveSelectedLayerOrder,
addCanvasSizedLayer: s.addCanvasSizedLayer,
importImageFromFile: s.importImageFromFile, importImageFromFile: s.importImageFromFile,
setImageLayerFaceBlur: s.setImageLayerFaceBlur, updateImageLayerSource: s.updateImageLayerSource,
setLayerEffect: s.setLayerEffect,
removeLayerEffect: s.removeLayerEffect,
setLayerVisible: s.setLayerVisible,
setEffectEnabled: s.setEffectEnabled,
setCanvasSize: s.setCanvasSize, setCanvasSize: s.setCanvasSize,
setTool: s.setTool, setTool: s.setTool,
undo: s.undo, undo: s.undo,
+43 -11
View File
@@ -2,14 +2,20 @@ import React from "react";
type UseEditorShortcutsOptions = { type UseEditorShortcutsOptions = {
onSave: () => void; onSave: () => void;
onUndo: () => void;
onRedo: () => void;
onCopy: () => void; onCopy: () => void;
onCut: () => void; onCut: () => void;
onPaste: () => void; onPaste: (e?: ClipboardEvent) => void;
onDelete: () => 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) { export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
const { onSave, onCopy, onCut, onPaste, onDelete } = options; const { onSave, onUndo, onRedo, onCopy, onCut, onPaste, onDelete } = options;
React.useEffect(() => { React.useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
@@ -19,31 +25,57 @@ export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
onSave(); onSave();
return; return;
} }
if (e.key.toLowerCase() === "c") { if (e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
onCopy(); onUndo();
return; return;
} }
if (e.key.toLowerCase() === "x") { if (e.key.toLowerCase() === "z" && e.shiftKey) {
e.preventDefault(); e.preventDefault();
onCut(); onRedo();
return; return;
} }
if (e.key.toLowerCase() === "v") { if (e.key.toLowerCase() === "y") {
e.preventDefault(); e.preventDefault();
onPaste(); onRedo();
return; return;
} }
} }
if (e.key === "Delete" || e.key === "Backspace") { 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(); e.preventDefault();
onDelete(); 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); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); window.addEventListener("copy", handleCopy);
}, [onCopy, onCut, onDelete, onPaste, onSave]); 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]);
} }
+29 -14
View File
@@ -1,25 +1,28 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; 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"; import { useFaceBlurWorkflow } from "./use-face-blur-workflow";
function makeImageLayer(overrides: Partial<Layer> = {}): Layer { function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
return { return {
id: "layer-1", id: "layer-1",
type: "image", type: "raster",
sourceUri: "data:image/png;base64,abc", sourceUri: "data:image/png;base64,abc",
x: 0, x: 0,
y: 0, y: 0,
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
...overrides, ...overrides,
}; };
} }
describe("useFaceBlurWorkflow", () => { describe("useFaceBlurWorkflow", () => {
it("selects all detected faces by default when no existing face blur", async () => { 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 selectedLayer = makeImageLayer();
const faceDetections = [ const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }, { x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
@@ -31,28 +34,37 @@ describe("useFaceBlurWorkflow", () => {
selectedLayer, selectedLayer,
faceDetectionsLayerId: "layer-1", faceDetectionsLayerId: "layer-1",
faceDetections, faceDetections,
setImageLayerFaceBlur, setLayerEffect,
removeLayerEffect,
}), }),
); );
await waitFor(() => { await waitFor(() => {
expect(result.current.selectedFaceIndices).toEqual([0, 1]); 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 () => { it("keeps selection empty when selected image already has blur", async () => {
const setImageLayerFaceBlur = vi.fn(); const setLayerEffect = vi.fn();
const selectedLayer = makeImageLayer({ const removeLayerEffect = vi.fn();
faceBlur: { method: "gaussian", amount: 14, regions: [{ x: 0, y: 0, width: 4, height: 4 }] }, 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(() => const { result } = renderHook(() =>
useFaceBlurWorkflow({ useFaceBlurWorkflow({
selectedLayer, selectedLayer,
faceDetectionsLayerId: "layer-1", faceDetectionsLayerId: "layer-1",
faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }], 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 () => { 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 selectedLayer = makeImageLayer();
const faceDetections = [ const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }, { x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
@@ -75,7 +88,8 @@ describe("useFaceBlurWorkflow", () => {
selectedLayer, selectedLayer,
faceDetectionsLayerId: "layer-1", faceDetectionsLayerId: "layer-1",
faceDetections, faceDetections,
setImageLayerFaceBlur, setLayerEffect,
removeLayerEffect,
}), }),
); );
@@ -93,10 +107,11 @@ describe("useFaceBlurWorkflow", () => {
result.current.blurFaces(result.current.selectedFaceIndices); result.current.blurFaces(result.current.selectedFaceIndices);
}); });
expect(setImageLayerFaceBlur).toHaveBeenCalledTimes(1); expect(setLayerEffect).toHaveBeenCalledTimes(1);
expect(setImageLayerFaceBlur).toHaveBeenCalledWith( expect(setLayerEffect).toHaveBeenCalledWith(
"layer-1", "layer-1",
expect.objectContaining({ expect.objectContaining({
kind: "face-blur",
method: "gaussian", method: "gaussian",
amount: 14, amount: 14,
regions: expect.arrayContaining([ regions: expect.arrayContaining([
+27 -18
View File
@@ -1,34 +1,37 @@
import React from "react"; 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"; import type { FaceDetectionOverlay } from "./use-face-detection";
type FaceBlurPreview = { type FaceBlurPreview = {
layerId: string; layerId: string;
method: FaceBlurMethod; effects: LayerEffect[];
amount: number;
regions: { x: number; y: number; width: number; height: number; sourceWidth: number; sourceHeight: number; censorColor?: string }[];
censorColor?: string;
}; };
type UseFaceBlurWorkflowOptions = { type UseFaceBlurWorkflowOptions = {
selectedLayer: Layer | null; selectedLayer: Layer | null;
faceDetectionsLayerId: string | null; faceDetectionsLayerId: string | null;
faceDetections: FaceDetectionOverlay[]; 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) { export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options; const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options;
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian"); const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
const [blurAmount, setBlurAmount] = React.useState(14); const [blurAmount, setBlurAmount] = React.useState(14);
const [censorColor, setCensorColor] = React.useState("#111111"); const [censorColor, setCensorColor] = React.useState("#111111");
const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]); const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null); const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(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<LayerEffect, { kind: "face-blur" }> => e.kind === "face-blur") ?? null,
[selectedLayer?.effects],
);
const buildBlurRegions = React.useCallback( const buildBlurRegions = React.useCallback(
(indices: number[]) => { (indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "image") return []; if (!selectedLayer || selectedLayer.type !== "raster") return [];
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return []; if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return [];
const indexSet = new Set(indices); const indexSet = new Set(indices);
return faceDetections return faceDetections
@@ -57,17 +60,19 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
}, []); }, []);
const clearBlur = React.useCallback(() => { const clearBlur = React.useCallback(() => {
if (!selectedLayer || selectedLayer.type !== "image") return; if (!selectedLayer) return;
setImageLayerFaceBlur(selectedLayer.id, undefined); removeLayerEffect(selectedLayer.id, "face-blur");
setFaceBlurPreview(null); setFaceBlurPreview(null);
}, [selectedLayer, setImageLayerFaceBlur]); }, [selectedLayer, removeLayerEffect]);
const blurFaces = React.useCallback( const blurFaces = React.useCallback(
(indices: number[]) => { (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; if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
const regions = buildBlurRegions(indices); const regions = buildBlurRegions(indices);
setImageLayerFaceBlur(selectedLayer.id, { setLayerEffect(selectedLayer.id, {
kind: "face-blur",
enabled: true,
method: blurMethod, method: blurMethod,
amount: blurAmount, amount: blurAmount,
regions, regions,
@@ -76,7 +81,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setSelectedFaceIndices([]); setSelectedFaceIndices([]);
setFaceBlurPreview(null); setFaceBlurPreview(null);
}, },
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur], [blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setLayerEffect],
); );
React.useEffect(() => { React.useEffect(() => {
@@ -85,7 +90,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setFaceBlurPreview(null); setFaceBlurPreview(null);
return; return;
} }
if (selectedLayer?.faceBlur) { if (faceBlurEffect) {
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : [])); setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
return; return;
} }
@@ -94,10 +99,10 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev; if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
return next; return next;
}); });
}, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]); }, [faceDetections, hasDetectableSelection, faceBlurEffect]);
React.useEffect(() => { React.useEffect(() => {
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") { if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
setFaceBlurPreview(null); setFaceBlurPreview(null);
return; return;
} }
@@ -109,10 +114,14 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setFaceBlurPreview({ setFaceBlurPreview({
layerId: selectedLayer.id, layerId: selectedLayer.id,
effects: [{
kind: "face-blur",
enabled: true,
method: blurMethod, method: blurMethod,
amount: blurAmount, amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices), regions: buildBlurRegions(selectedFaceIndices),
censorColor, censorColor,
}],
}); });
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]); }, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
+99
View File
@@ -0,0 +1,99 @@
export type BrushOptions = {
color: string;
size: number;
opacity: number;
hardness: number; // 01: 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<string> {
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;
});
}
+3 -1
View File
@@ -11,7 +11,7 @@ describe("buildFaceLabelOverlays", () => {
const layers = [ const layers = [
{ {
id: "layer-1", id: "layer-1",
type: "image" as const, type: "raster" as const,
x: 20, x: 20,
y: 30, y: 30,
width: 180, width: 180,
@@ -19,6 +19,8 @@ describe("buildFaceLabelOverlays", () => {
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
}, },
]; ];
+1 -1
View File
@@ -13,7 +13,7 @@ export function buildFaceLabelOverlays(
const layer = layers.find((item) => item.id === faceOverlayLayerId); const layer = layers.find((item) => item.id === faceOverlayLayerId);
if (!layer) return []; 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 layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined); const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
if (!layerWidth || !layerHeight) return []; if (!layerWidth || !layerHeight) return [];
+5 -1
View File
@@ -1,4 +1,4 @@
import type { EditorToolId } from "../store/editor-store"; import type { EditorToolId } from "@pien-studio/editor-core";
export type ToolModeController = { export type ToolModeController = {
kind: "mode"; kind: "mode";
@@ -22,6 +22,8 @@ type CreateEditorToolControllersOptions = {
pointer: string; pointer: string;
pan: string; pan: string;
face: string; face: string;
fill: string;
brush: string;
text: string; text: string;
image: string; image: string;
}; };
@@ -32,6 +34,8 @@ export function createEditorToolControllers(options: CreateEditorToolControllers
{ kind: "mode", id: "pointer", label: options.labels.pointer }, { kind: "mode", id: "pointer", label: options.labels.pointer },
{ kind: "mode", id: "hand", label: options.labels.pan }, { kind: "mode", id: "hand", label: options.labels.pan },
{ kind: "mode", id: "face", label: options.labels.face }, { 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: "add-text", label: options.labels.text, run: options.onAddTextLayer },
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage }, { kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
]; ];
+176
View File
@@ -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<CanvasRenderingContext2D>).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();
});
});
@@ -1,6 +1,5 @@
import type { FaceBlurSettings } from "@pien-studio/types"; import type { FaceBlurEffect } from "@pien-studio/types";
import type { EffectRenderer, EffectRenderContext } from "./types";
type BlurRegion = FaceBlurSettings["regions"][number];
function drawPixelatedRegion( function drawPixelatedRegion(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
@@ -21,36 +20,12 @@ function drawPixelatedRegion(
const sampleCtx = sampleCanvas.getContext("2d"); const sampleCtx = sampleCanvas.getContext("2d");
if (!sampleCtx) return; if (!sampleCtx) return;
sampleCtx.imageSmoothingEnabled = false; sampleCtx.imageSmoothingEnabled = false;
sampleCtx.drawImage( sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height);
source,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
sampleCanvas.width,
sampleCanvas.height,
);
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
ctx.drawImage( ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight);
sampleCanvas,
0,
0,
sampleCanvas.width,
sampleCanvas.height,
targetX,
targetY,
targetWidth,
targetHeight,
);
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
} }
function canUseCanvasFilter(ctx: CanvasRenderingContext2D) {
return "filter" in ctx && typeof ctx.filter === "string";
}
function drawBlurredRegionFallback( function drawBlurredRegionFallback(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
source: CanvasImageSource, source: CanvasImageSource,
@@ -69,86 +44,36 @@ function drawBlurredRegionFallback(
regionCanvas.height = Math.max(1, Math.round(targetHeight)); regionCanvas.height = Math.max(1, Math.round(targetHeight));
const regionCtx = regionCanvas.getContext("2d"); const regionCtx = regionCanvas.getContext("2d");
if (!regionCtx) return; 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 scale = Math.max(0.04, Math.min(0.5, 1 / Math.max(2, amount / 2)));
const blurCanvas = document.createElement("canvas"); const blurCanvas = document.createElement("canvas");
blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale)); blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale));
blurCanvas.height = Math.max(1, Math.round(regionCanvas.height * scale)); blurCanvas.height = Math.max(1, Math.round(regionCanvas.height * scale));
const blurCtx = blurCanvas.getContext("2d"); const blurCtx = blurCanvas.getContext("2d");
if (!blurCtx) return; if (!blurCtx) return;
blurCtx.imageSmoothingEnabled = true; blurCtx.imageSmoothingEnabled = true;
blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height); blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height);
regionCtx.imageSmoothingEnabled = true;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height); regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height);
regionCtx.drawImage( regionCtx.drawImage(blurCanvas, 0, 0, blurCanvas.width, blurCanvas.height, 0, 0, regionCanvas.width, regionCanvas.height);
blurCanvas,
0,
0,
blurCanvas.width,
blurCanvas.height,
0,
0,
regionCanvas.width,
regionCanvas.height,
);
blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height); blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height);
blurCtx.drawImage( blurCtx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, 0, 0, blurCanvas.width, blurCanvas.height);
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, ctx: CanvasRenderingContext2D,
image: HTMLImageElement, image: HTMLImageElement,
blur: { effect: FaceBlurEffect,
method: FaceBlurSettings["method"];
amount: number;
regions: BlurRegion[];
censorColor?: string;
},
targetWidth: number, targetWidth: number,
targetHeight: number, targetHeight: number,
): void { ) {
if (!blur.regions.length) return; if (!effect.regions.length) return;
const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth); const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth);
const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight); 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 sourceWidth = region.sourceWidth ?? 0;
const sourceHeight = region.sourceHeight ?? 0; const sourceHeight = region.sourceHeight ?? 0;
const hasSourceDims = sourceWidth > 0 && 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 y = Math.max(0, Math.floor(region.y * scaleY));
const w = Math.max(1, Math.floor(region.width * scaleX)); const w = Math.max(1, Math.floor(region.width * scaleX));
const h = Math.max(1, Math.floor(region.height * scaleY)); const h = Math.max(1, Math.floor(region.height * scaleY));
const sx0 = hasSourceDims const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX));
? region.x const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY));
: Math.max(0, Math.floor(region.x * legacyScaleX)); const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX));
const sy0 = hasSourceDims const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY));
? 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") { if (effect.method === "censor") {
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111"; ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111";
ctx.fillRect(x, y, w, h); ctx.fillRect(x, y, w, h);
continue; continue;
} }
if (effect.method === "pixelate") {
if (blur.method === "pixelate") { drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, Math.max(4, Math.round(effect.amount / 2)));
const pixelSize = Math.max(4, Math.round(blur.amount / 2));
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, pixelSize);
continue; continue;
} }
if ("filter" in ctx && typeof ctx.filter === "string") {
if (canUseCanvasFilter(ctx)) {
ctx.save(); 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.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
ctx.restore(); ctx.restore();
continue; continue;
} }
drawBlurredRegionFallback(ctx, image, sx0, sy0, sw, sh, x, y, w, h, effect.amount);
drawBlurredRegionFallback(
ctx,
image,
sx0,
sy0,
sw,
sh,
x,
y,
w,
h,
blur.amount,
);
} }
} }
export function renderImageWithFaceBlur( function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
ctx: CanvasRenderingContext2D, const { ctx, image, targetWidth, targetHeight } = context;
image: HTMLImageElement, if (!effect.regions.length) {
blur:
| {
method: FaceBlurSettings["method"];
amount: number;
regions: BlurRegion[];
censorColor?: string;
}
| undefined,
targetWidth: number,
targetHeight: number,
): void {
if (!blur || blur.regions.length === 0) {
ctx.drawImage(image, 0, 0, targetWidth, targetHeight); ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
return; return;
} }
const canBlurAtSourceSize = blur.regions.every( const allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0);
(region) => (region.sourceWidth ?? 0) > 0 && (region.sourceHeight ?? 0) > 0, if (!allHaveSourceDims) {
);
if (!canBlurAtSourceSize) {
ctx.drawImage(image, 0, 0, targetWidth, targetHeight); ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
renderFaceBlurRegions(ctx, image, blur, targetWidth, targetHeight); renderRegions(ctx, image, effect, targetWidth, targetHeight);
return; return;
} }
@@ -244,24 +131,15 @@ export function renderImageWithFaceBlur(
ctx.drawImage(image, 0, 0, targetWidth, targetHeight); ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
return; return;
} }
sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height); sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height);
renderFaceBlurRegions( renderRegions(sourceCtx, image, effect, sourceCanvas.width, sourceCanvas.height);
sourceCtx, ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
image,
blur,
sourceCanvas.width,
sourceCanvas.height,
);
ctx.drawImage(
sourceCanvas,
0,
0,
sourceCanvas.width,
sourceCanvas.height,
0,
0,
targetWidth,
targetHeight,
);
} }
export const faceBlurRenderer: EffectRenderer<FaceBlurEffect> = {
kind: "face-blur",
render: ({ ctx, image, targetWidth, targetHeight }, effect) => {
renderRegions(ctx, image, effect, targetWidth, targetHeight);
},
renderLayer,
};
+46
View File
@@ -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<string, EffectRenderer>(
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);
}
}
+16
View File
@@ -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<T extends LayerEffect = LayerEffect> = {
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;
};
+11 -30
View File
@@ -1,5 +1,5 @@
import type { Layer, Project } from "@pien-studio/types"; import type { Layer, Project } from "@pien-studio/types";
import { renderImageWithFaceBlur } from "./face-blur-renderer"; import { renderLayerWithEffects } from "./effects/registry";
type ExportOptions = { type ExportOptions = {
isDark: boolean; isDark: boolean;
@@ -20,11 +20,7 @@ function loadImage(src: string) {
}); });
} }
function drawFallbackLayer( function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
ctx: CanvasRenderingContext2D,
layer: Layer,
isDark: boolean,
) {
const text = layer.name ?? layer.type; const text = layer.name ?? layer.type;
const width = Math.max(80, layer.width ?? 120); const width = Math.max(80, layer.width ?? 120);
const height = Math.max(34, layer.height ?? 40); const height = Math.max(34, layer.height ?? 40);
@@ -49,24 +45,15 @@ function drawFallbackLayer(
ctx.stroke(); ctx.stroke();
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430"; ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
ctx.font = ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.fillText(text, width / 2, height / 2); ctx.fillText(text, width / 2, height / 2);
} }
async function drawLayer( async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
ctx: CanvasRenderingContext2D, const width = layer.width ?? (layer.type === "raster" ? Math.round(200 * layer.scale) : 120);
layer: Layer, const height = layer.height ?? (layer.type === "raster" ? Math.round(150 * layer.scale) : 40);
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);
ctx.save(); ctx.save();
ctx.globalAlpha = clampOpacity(layer.opacity); ctx.globalAlpha = clampOpacity(layer.opacity);
@@ -74,10 +61,10 @@ async function drawLayer(
ctx.rotate((layer.rotation * Math.PI) / 180); ctx.rotate((layer.rotation * Math.PI) / 180);
ctx.translate(-width / 2, -height / 2); 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 { try {
const image = await loadImage(layer.sourceUri); const image = await loadImage(layer.sourceUri);
renderImageWithFaceBlur(ctx, image, layer.faceBlur, width, height); renderLayerWithEffects(ctx, image, layer.effects, width, height);
} catch { } catch {
drawFallbackLayer(ctx, layer, isDark); drawFallbackLayer(ctx, layer, isDark);
} }
@@ -88,14 +75,8 @@ async function drawLayer(
ctx.restore(); ctx.restore();
} }
export async function exportProjectAsPng( export async function exportProjectAsPng(project: Project, options: ExportOptions) {
project: Project, const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
options: ExportOptions,
) {
const pixelRatio = Math.max(
1,
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
);
const { width, height } = project.canvas; const { width, height } = project.canvas;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = width * pixelRatio; canvas.width = width * pixelRatio;
@@ -103,10 +84,10 @@ export async function exportProjectAsPng(
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cannot create export canvas context"); if (!ctx) throw new Error("Cannot create export canvas context");
ctx.scale(pixelRatio, pixelRatio); ctx.scale(pixelRatio, pixelRatio);
for (const layer of project.layers) { for (const layer of project.layers) {
if (layer.visible === false) continue;
await drawLayer(ctx, layer, options.isDark); await drawLayer(ctx, layer, options.isDark);
} }
-355
View File
@@ -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<CanvasRenderingContext2D>).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();
});
});
+96
View File
@@ -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<string> {
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;
});
}
+10 -10
View File
@@ -10,7 +10,7 @@ function makeProject(): Project {
updatedAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z",
aspectRatio: "1:1", aspectRatio: "1:1",
canvas: { width: 100, height: 100, unit: "px" }, 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", () => { it("detects face blur region changes", () => {
const a = makeProject(); const a = makeProject();
const b = makeProject(); const b = makeProject();
a.layers[0].type = "image"; a.layers[0].type = "raster";
b.layers[0].type = "image"; b.layers[0].type = "raster";
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }; 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].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, 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); expect(hasProjectChanged(a, b)).toBe(true);
}); });
it("detects layer order changes", () => { it("detects layer order changes", () => {
const a = makeProject(); const a = makeProject();
const b = makeProject(); const b = makeProject();
a.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 }); 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]]; b.layers = [b.layers[1], b.layers[0]];
expect(hasProjectChanged(a, b)).toBe(true); expect(hasProjectChanged(a, b)).toBe(true);
}); });
@@ -58,9 +58,9 @@ describe("hasProjectChanged", () => {
it("detects face blur removal", () => { it("detects face blur removal", () => {
const a = makeProject(); const a = makeProject();
const b = makeProject(); const b = makeProject();
a.layers[0].type = "image"; a.layers[0].type = "raster";
b.layers[0].type = "image"; b.layers[0].type = "raster";
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }; 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); expect(hasProjectChanged(a, b)).toBe(true);
}); });
}); });
+1 -20
View File
@@ -32,26 +32,7 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
return true; return true;
} }
const blurA = a.faceBlur; if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true;
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;
}
}
} }
return false; return false;
+66
View File
@@ -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<string, ToolUiDefinition>(
definitions.map((d) => [d.id, d]),
);
export function getToolUiDefinition(id: string): ToolUiDefinition | undefined {
return toolUiRegistry.get(id);
}
export function getAllToolUiDefinitions(): ToolUiDefinition[] {
return definitions;
}
+10
View File
@@ -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;
};
+6 -1
View File
@@ -47,6 +47,7 @@
"unsavedChanges": "Unsaved changes", "unsavedChanges": "Unsaved changes",
"saved": "Saved", "saved": "Saved",
"layers": "Layers", "layers": "Layers",
"newLayer": "New layer",
"up": "Up", "up": "Up",
"down": "Down", "down": "Down",
"delete": "Delete", "delete": "Delete",
@@ -86,9 +87,13 @@
"faceDetectionTip": "{count} face(s) found. Select an image layer with a clear, front-facing face for best results.", "faceDetectionTip": "{count} face(s) found. Select an image layer with a clear, front-facing face for best results.",
"toolPointer": "Pointer", "toolPointer": "Pointer",
"toolPan": "Pan", "toolPan": "Pan",
"toolFace": "Face", "toolFace": "Face Blur",
"toolFill": "Fill",
"toolBrush": "Brush",
"toolText": "Text", "toolText": "Text",
"toolImage": "Image", "toolImage": "Image",
"fillColor": "Fill color",
"fillTolerance": "Tolerance",
"canvasSizeTitle": "Canvas Size", "canvasSizeTitle": "Canvas Size",
"modePreset": "Preset", "modePreset": "Preset",
"modeCustom": "Custom", "modeCustom": "Custom",
+6 -1
View File
@@ -47,6 +47,7 @@
"unsavedChanges": "未保存の変更", "unsavedChanges": "未保存の変更",
"saved": "保存済み", "saved": "保存済み",
"layers": "レイヤー", "layers": "レイヤー",
"newLayer": "新しいレイヤー",
"up": "上へ", "up": "上へ",
"down": "下へ", "down": "下へ",
"delete": "削除", "delete": "削除",
@@ -86,9 +87,13 @@
"faceDetectionTip": "{count}件の顔が見つかりました。正面を向いた顔がはっきり写っている画像レイヤーを選ぶと、より良い結果になります。", "faceDetectionTip": "{count}件の顔が見つかりました。正面を向いた顔がはっきり写っている画像レイヤーを選ぶと、より良い結果になります。",
"toolPointer": "ポインター", "toolPointer": "ポインター",
"toolPan": "パン", "toolPan": "パン",
"toolFace": "顔", "toolFace": "顔ぼかし",
"toolFill": "塗りつぶし",
"toolBrush": "ブラシ",
"toolText": "テキスト", "toolText": "テキスト",
"toolImage": "画像", "toolImage": "画像",
"fillColor": "塗りつぶし色",
"fillTolerance": "許容値",
"canvasSizeTitle": "キャンバスサイズ", "canvasSizeTitle": "キャンバスサイズ",
"modePreset": "プリセット", "modePreset": "プリセット",
"modeCustom": "カスタム", "modeCustom": "カスタム",
+6 -1
View File
@@ -47,6 +47,7 @@
"unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่บันทึก", "unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่บันทึก",
"saved": "บันทึกแล้ว", "saved": "บันทึกแล้ว",
"layers": "เลเยอร์", "layers": "เลเยอร์",
"newLayer": "เลเยอร์ใหม่",
"up": "ขึ้น", "up": "ขึ้น",
"down": "ลง", "down": "ลง",
"delete": "ลบ", "delete": "ลบ",
@@ -86,9 +87,13 @@
"faceDetectionTip": "พบ {count} ใบหน้า เลือกเลเยอร์รูปภาพที่มีใบหน้าหันตรงและชัดเจนเพื่อผลลัพธ์ที่ดีที่สุด", "faceDetectionTip": "พบ {count} ใบหน้า เลือกเลเยอร์รูปภาพที่มีใบหน้าหันตรงและชัดเจนเพื่อผลลัพธ์ที่ดีที่สุด",
"toolPointer": "ตัวชี้", "toolPointer": "ตัวชี้",
"toolPan": "เลื่อน", "toolPan": "เลื่อน",
"toolFace": "ใบหน้า", "toolFace": "เบลอใบหน้า",
"toolFill": "เติมสี",
"toolBrush": "แปรง",
"toolText": "ข้อความ", "toolText": "ข้อความ",
"toolImage": "รูปภาพ", "toolImage": "รูปภาพ",
"fillColor": "สีเติม",
"fillTolerance": "ความคลาดเคลื่อน",
"canvasSizeTitle": "ขนาดแคนวาส", "canvasSizeTitle": "ขนาดแคนวาส",
"modePreset": "พรีเซ็ต", "modePreset": "พรีเซ็ต",
"modeCustom": "กำหนดเอง", "modeCustom": "กำหนดเอง",
+3 -3
View File
@@ -17,7 +17,7 @@ describe("editor store", () => {
it("imports and exports project json", () => { it("imports and exports project json", () => {
useEditorStore.getState().resetProject(); useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("image"); useEditorStore.getState().addLayerByType("raster");
const json = useEditorStore.getState().exportProjectToJson(); const json = useEditorStore.getState().exportProjectToJson();
useEditorStore.getState().resetProject(); useEditorStore.getState().resetProject();
@@ -46,7 +46,7 @@ describe("editor store", () => {
it("records pointer draft transforms in undo history on commit", () => { it("records pointer draft transforms in undo history on commit", () => {
useEditorStore.getState().resetProject(); useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("image"); useEditorStore.getState().addLayerByType("raster");
const initial = useEditorStore.getState().project.layers[0]; const initial = useEditorStore.getState().project.layers[0];
expect(initial).toBeDefined(); expect(initial).toBeDefined();
@@ -63,7 +63,7 @@ describe("editor store", () => {
it("reorders layers up and down", () => { it("reorders layers up and down", () => {
useEditorStore.getState().resetProject(); useEditorStore.getState().resetProject();
useEditorStore.getState().addLayerByType("text"); useEditorStore.getState().addLayerByType("text");
useEditorStore.getState().addLayerByType("image"); useEditorStore.getState().addLayerByType("raster");
const layers = useEditorStore.getState().project.layers; const layers = useEditorStore.getState().project.layers;
const [first, second] = layers; const [first, second] = layers;
expect(first.id).not.toBe(second.id); expect(first.id).not.toBe(second.id);
+78 -56
View File
@@ -10,10 +10,16 @@ import {
reorderLayer, reorderLayer,
serializeProjectFile, serializeProjectFile,
setCanvasSize as applyCanvasSize, setCanvasSize as applyCanvasSize,
setLayerEffect,
removeLayerEffect,
setLayerVisible,
setEffectEnabled,
updateLayerTransform, updateLayerTransform,
normalizeProject, normalizeProject,
getAllTools,
type EditorToolId,
} from "@pien-studio/editor-core"; } 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 { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage";
import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants"; import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants";
import { hasProjectChanged } from "../lib/project-equality"; import { hasProjectChanged } from "../lib/project-equality";
@@ -29,13 +35,10 @@ import {
const DRAFT_TRANSFORM_EPSILON = 0.01; const DRAFT_TRANSFORM_EPSILON = 0.01;
export const EDITOR_TOOLS = { export { EditorToolId };
pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true },
hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false },
face: { id: "face", allowsSelection: true, allowsLayerEditing: false },
} as const;
export type EditorToolId = keyof typeof EDITOR_TOOLS; const allTools = getAllTools();
export const EDITOR_TOOLS = Object.fromEntries(allTools.map((t) => [t.id, t])) as Record<string, (typeof allTools)[number]>;
type TransactionState = { type TransactionState = {
baselineProject: Project; baselineProject: Project;
@@ -52,7 +55,6 @@ type EditorState = {
clipboardLayer: Layer | null; clipboardLayer: Layer | null;
isDirty: boolean; isDirty: boolean;
transaction: TransactionState | null; transaction: TransactionState | null;
tools: typeof EDITOR_TOOLS;
startTransaction: () => void; startTransaction: () => void;
commitTransaction: () => void; commitTransaction: () => void;
cancelTransaction: () => void; cancelTransaction: () => void;
@@ -70,15 +72,19 @@ type EditorState = {
selectLayer: (layerId: string | null) => void; selectLayer: (layerId: string | null) => void;
copySelectedLayer: () => void; copySelectedLayer: () => void;
cutSelectedLayer: () => void; cutSelectedLayer: () => void;
pasteLayer: () => void; pasteLayer: (e?: ClipboardEvent) => void;
resetProject: () => void; resetProject: () => void;
saveCurrentProject: () => Promise<void>; saveCurrentProject: () => Promise<void>;
loadProjectById: (projectId: string) => Promise<boolean>; loadProjectById: (projectId: string) => Promise<boolean>;
setProject: (project: Project) => void; setProject: (project: Project) => void;
importProjectFromJson: (raw: string) => { ok: boolean; error?: string }; importProjectFromJson: (raw: string) => { ok: boolean; error?: string };
addCanvasSizedLayer: (sourceUri: string, name?: string) => void;
importImageFromFile: (file: File) => Promise<void>; importImageFromFile: (file: File) => Promise<void>;
updateImageLayerSource: (layerId: string, sourceUri: string) => void; 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; setCanvasSize: (width: number, height: number) => void;
exportProjectToJson: () => string; exportProjectToJson: () => string;
undo: () => void; undo: () => void;
@@ -129,7 +135,6 @@ export const useEditorStore = create<EditorState>((set, get) => ({
clipboardLayer: null, clipboardLayer: null,
isDirty: false, isDirty: false,
transaction: null, transaction: null,
tools: EDITOR_TOOLS,
startTransaction: () => startTransaction: () =>
set((state) => { set((state) => {
@@ -172,7 +177,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
applyProjectDraft: (project) => set(() => ({ project })), applyProjectDraft: (project) => set(() => ({ project })),
setTool: (tool) => { setTool: (tool) => {
if (!(tool in EDITOR_TOOLS)) return; if (!EDITOR_TOOLS[tool]) return;
set({ tool }); set({ tool });
}, },
@@ -183,6 +188,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
return withCommittedProject(state, nextProject, { selectedLayerId: layer.id }); 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) => setSelectedLayerPosition: (x, y) =>
set((state) => { set((state) => {
if (!state.selectedLayerId) return state; if (!state.selectedLayerId) return state;
@@ -218,8 +231,8 @@ export const useEditorStore = create<EditorState>((set, get) => ({
const nextHeight = Math.max(MIN_LAYER_SIZE, height); const nextHeight = Math.max(MIN_LAYER_SIZE, height);
if (current) { if (current) {
const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined); const currentWidth = current.width ?? (current.type === "raster" ? Math.round(200 * current.scale) : undefined);
const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined); const currentHeight = current.height ?? (current.type === "raster" ? Math.round(150 * current.scale) : undefined);
if ( if (
typeof currentWidth === "number" && typeof currentWidth === "number" &&
@@ -284,14 +297,18 @@ export const useEditorStore = create<EditorState>((set, get) => ({
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) }); return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
}), }),
pasteLayer: () => { pasteLayer: (e?: ClipboardEvent) => {
const state = get(); const state = get();
if (!state.clipboardLayer) {
navigator.clipboard.read().then(async (clipboardItems) => { // Internal layer clipboard takes priority
for (const item of clipboardItems) { if (state.clipboardLayer) {
for (const type of item.types) { const base = state.clipboardLayer;
if (type.startsWith("image/")) { const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
const blob = await item.getType(type); set((s) => withCommittedProject(s, addLayer(s.project, pasted), { selectedLayerId: pasted.id }));
return;
}
async function pasteImageBlob(blob: Blob) {
const reader = new FileReader(); const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve, reject) => { const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
@@ -301,11 +318,10 @@ export const useEditorStore = create<EditorState>((set, get) => ({
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => { const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
const image = new Image(); const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
image.onerror = () => image.onerror = () => resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
image.src = dataUrl; image.src = dataUrl;
}); });
const layer = createLayer("image", { const layer = createLayer("raster", {
name: "Image", name: "Image",
sourceUri: dataUrl, sourceUri: dataUrl,
x: DEFAULT_IMAGE_IMPORT.offsetX, x: DEFAULT_IMAGE_IMPORT.offsetX,
@@ -314,37 +330,31 @@ export const useEditorStore = create<EditorState>((set, get) => ({
height: Math.max(1, Math.round(imageSize.height)), height: Math.max(1, Math.round(imageSize.height)),
}); });
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id })); 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; 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(() => {}); }).catch(() => {});
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 }));
}, },
resetProject: () => { resetProject: () => {
@@ -412,7 +422,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight }); resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
image.src = dataUrl; image.src = dataUrl;
}); });
const layer = createLayer("image", { const layer = createLayer("raster", {
name, name,
sourceUri: dataUrl, sourceUri: dataUrl,
x: DEFAULT_IMAGE_IMPORT.offsetX, x: DEFAULT_IMAGE_IMPORT.offsetX,
@@ -428,20 +438,32 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set((state) => { set((state) => {
if (!sourceUri) return state; if (!sourceUri) return state;
const layer = state.project.layers.find((item) => item.id === layerId); 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; if (layer.sourceUri === sourceUri) return state;
const nextProject = updateLayerTransform(state.project, layerId, { sourceUri }); const nextProject = updateLayerTransform(state.project, layerId, { sourceUri });
return withCommittedProject(state, nextProject); return withCommittedProject(state, nextProject);
}), }),
setImageLayerFaceBlur: (layerId, faceBlur) => setLayerEffect: (layerId, effect) =>
set((state) => { set((state) => {
const layer = state.project.layers.find((item) => item.id === layerId); const layer = state.project.layers.find((item) => item.id === layerId);
if (!layer || layer.type !== "image") return state; if (!layer) return state;
const nextProject = updateLayerTransform(state.project, layerId, { faceBlur }); return withCommittedProject(state, setLayerEffect(state.project, layerId, effect));
return withCommittedProject(state, nextProject);
}), }),
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))), setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))),
undo: () => undo: () =>
@@ -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<FaceBlurEffect> = {
kind: "face-blur",
normalize: normalizeFaceBlur,
};
@@ -0,0 +1,23 @@
import type { LayerEffect } from "@pien-studio/types";
import { faceBlurDefinition } from "./face-blur";
export type EffectDefinition<T extends LayerEffect = LayerEffect> = {
kind: T["kind"];
normalize: (effect: T) => T;
};
const definitions = [faceBlurDefinition] as EffectDefinition[];
const effectRegistry = new Map<string, EffectDefinition>(
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);
}
+11 -1
View File
@@ -19,12 +19,14 @@ describe("editor-core", () => {
const project = createProject("new"); const project = createProject("new");
const updated = addLayer(project, { const updated = addLayer(project, {
id: "l1", id: "l1",
type: "image", type: "raster",
x: 0, x: 0,
y: 0, y: 0,
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
}); });
expect(updated.layers).toHaveLength(1); expect(updated.layers).toHaveLength(1);
@@ -41,6 +43,8 @@ describe("editor-core", () => {
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
}); });
const moved = moveLayer(project, "l1", { dx: 15, dy: -5 }); const moved = moveLayer(project, "l1", { dx: 15, dy: -5 });
@@ -57,6 +61,8 @@ describe("editor-core", () => {
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
}); });
const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 }); const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 });
@@ -74,6 +80,8 @@ describe("editor-core", () => {
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
}); });
const withSecond = addLayer(withFirst, { const withSecond = addLayer(withFirst, {
id: "l2", id: "l2",
@@ -83,6 +91,8 @@ describe("editor-core", () => {
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
effects: [],
visible: true,
}); });
const reordered = reorderLayer(withSecond, "l1", 1); const reordered = reorderLayer(withSecond, "l1", 1);
+24 -200
View File
@@ -1,212 +1,36 @@
import { export { createProject, setCanvasSize } from "./project";
PRESET_CANVAS_SIZES, export { createLayer, addLayer, removeLayer, moveLayer, updateLayerTransform, reorderLayer, setLayerEffect, removeLayerEffect, setLayerVisible, setEffectEnabled } from "./layers";
ProjectFileSchema, export type { LayerFactoryOptions, TransformPatch } from "./layers";
type AspectRatio, export { normalizeProject } from "./normalize";
type FaceBlurSettings, export { serializeProjectFile, parseProjectFile } from "./serialization";
type Layer, export { getEffectDefinition, normalizeEffect } from "./effects/registry";
type Project, export type { EffectDefinition } from "./effects/registry";
type ProjectFile, export { faceBlurDefinition } from "./effects/face-blur";
} from "@pien-studio/types"; export { getToolDefinition, getAllTools } from "./tools/registry";
export type { ToolDefinition, ToolInteractionMode, EditorToolId } from "./tools/registry";
const DEFAULT_ASPECT: AspectRatio = "4:5"; import type { Layer, LayerEffect, Project } from "@pien-studio/types";
import { addLayer, moveLayer, removeLayer, reorderLayer, setLayerEffect, updateLayerTransform } from "./layers";
function defaultCanvas(aspect: AspectRatio) { import { setCanvasSize } from "./project";
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;
};
export type EditorOperation = export type EditorOperation =
| { type: "addLayer"; layer: Layer } | { type: "addLayer"; layer: Layer }
| { type: "removeLayer"; layerId: string } | { type: "removeLayer"; layerId: string }
| { type: "moveLayer"; layerId: string; delta: Delta } | { type: "moveLayer"; layerId: string; delta: { dx: number; dy: number } }
| { type: "updateLayerTransform"; layerId: string; patch: TransformPatch } | { type: "updateLayerTransform"; layerId: string; patch: import("./layers").TransformPatch }
| { type: "reorderLayer"; layerId: string; toIndex: number } | { type: "reorderLayer"; layerId: string; toIndex: number }
| { type: "setLayerEffect"; layerId: string; effect: LayerEffect }
| { type: "setCanvasSize"; width: number; height: number; unit?: "px" | "in" | "cm" }; | { 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 { export function applyOperation(project: Project, operation: EditorOperation): Project {
switch (operation.type) { switch (operation.type) {
case "addLayer": case "addLayer": return addLayer(project, operation.layer);
return addLayer(project, operation.layer); case "removeLayer": return removeLayer(project, operation.layerId);
case "removeLayer": case "moveLayer": return moveLayer(project, operation.layerId, operation.delta);
return removeLayer(project, operation.layerId); case "updateLayerTransform": return updateLayerTransform(project, operation.layerId, operation.patch);
case "moveLayer": case "reorderLayer": return reorderLayer(project, operation.layerId, operation.toIndex);
return moveLayer(project, operation.layerId, operation.delta); case "setLayerEffect": return setLayerEffect(project, operation.layerId, operation.effect);
case "updateLayerTransform": case "setCanvasSize": return setCanvasSize(project, operation.width, operation.height, operation.unit);
return updateLayerTransform(project, operation.layerId, operation.patch); default: return project;
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" };
} }
} }
+114
View File
@@ -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,
),
);
}
+22
View File
@@ -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) : [],
})),
};
}
+38
View File
@@ -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(),
};
}
+26
View File
@@ -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" };
}
}
@@ -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<string, ToolDefinition>(
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"];
+19 -11
View File
@@ -72,7 +72,7 @@ function makeMalformedProject(id: string): Project {
layers: [ layers: [
{ {
id: "layer-1", id: "layer-1",
type: "image", type: "raster",
x: 10, x: 10,
y: 10, y: 10,
width: 10.4, width: 10.4,
@@ -80,24 +80,25 @@ function makeMalformedProject(id: string): Project {
scale: 0, scale: 0,
rotation: 720.4, rotation: 720.4,
opacity: 0.75, opacity: 0.75,
effects: [],
}, },
], ],
}; };
} }
function makeLegacyFaceBlurProject(id: string): Project { function makeFaceBlurProject(id: string): Project {
const now = new Date().toISOString(); const now = new Date().toISOString();
return { return {
id, id,
title: "legacy", title: "faceblur",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
aspectRatio: "4:5", aspectRatio: "4:5",
canvas: { width: 1200, height: 1500, unit: "px" }, canvas: { width: 1200, height: 1500, unit: "px" },
layers: [ layers: [
{ {
id: "image-legacy", id: "image-faceblur",
type: "image", type: "raster",
x: 30, x: 30,
y: 40, y: 40,
width: 600, width: 600,
@@ -105,11 +106,16 @@ function makeLegacyFaceBlurProject(id: string): Project {
scale: 1, scale: 1,
rotation: 0, rotation: 0,
opacity: 1, opacity: 1,
faceBlur: { visible: true,
effects: [
{
kind: "face-blur",
enabled: true,
method: "gaussian", method: "gaussian",
amount: 14, amount: 14,
regions: [{ x: 120, y: 160, width: 180, height: 200 }], 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); expect(raw?.layers[0]?.height).toBe(11.6);
}); });
it("loads legacy projects without source dimensions through storage and normalize flow", async () => { it("loads projects with face-blur effect through storage and normalize flow", async () => {
const source = makeLegacyFaceBlurProject("project-legacy-faceblur"); const source = makeFaceBlurProject("project-faceblur");
await seedProject(source); await seedProject(source);
const loaded = await getProjectById(source.id); const loaded = await getProjectById(source.id);
const region = loaded?.layers[0]?.faceBlur?.regions[0]; const effect = loaded?.layers[0]?.effects.find((e) => e.kind === "face-blur");
expect(region).toBeDefined(); expect(effect).toBeDefined();
const region = effect?.kind === "face-blur" ? effect.regions[0] : undefined;
expect(region?.x).toBe(120); expect(region?.x).toBe(120);
expect(region?.sourceWidth).toBeUndefined(); expect(region?.sourceWidth).toBeUndefined();
expect(region?.sourceHeight).toBeUndefined(); expect(region?.sourceHeight).toBeUndefined();
const listed = await loadProjects(); 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?.width).toBe(180);
expect(listedRegion?.sourceWidth).toBeUndefined(); expect(listedRegion?.sourceWidth).toBeUndefined();
expect(listedRegion?.sourceHeight).toBeUndefined(); expect(listedRegion?.sourceHeight).toBeUndefined();
+1 -1
View File
@@ -100,7 +100,7 @@ function makeLinkId(projectId: string, layerId: string): string {
} }
function isBinaryLayer(layer: Layer): boolean { function isBinaryLayer(layer: Layer): boolean {
return layer.type === "image" || layer.type === "sticker"; return layer.type === "raster" || layer.type === "sticker";
} }
function inferMimeType(layer: Layer): string { function inferMimeType(layer: Layer): string {
+12 -4
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; 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"]); export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]);
@@ -14,20 +14,25 @@ export const FaceBlurRegionSchema = z.object({
censorColor: z.string().optional(), 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, method: FaceBlurMethodSchema,
amount: z.number().int().min(4).max(40), amount: z.number().int().min(4).max(40),
regions: z.array(FaceBlurRegionSchema), regions: z.array(FaceBlurRegionSchema),
censorColor: z.string().optional(), censorColor: z.string().optional(),
}); });
export const LayerEffectSchema = FaceBlurEffectSchema;
export const LayerSchema = z.object({ export const LayerSchema = z.object({
id: z.string(), id: z.string(),
type: LayerTypeSchema, type: LayerTypeSchema,
name: z.string().optional(), name: z.string().optional(),
assetId: z.string().optional(), assetId: z.string().optional(),
sourceUri: z.string().optional(), sourceUri: z.string().optional(),
faceBlur: FaceBlurSettingsSchema.optional(), effects: z.array(LayerEffectSchema).default([]),
visible: z.boolean().default(true),
x: z.number(), x: z.number(),
y: z.number(), y: z.number(),
width: z.number().optional(), width: z.number().optional(),
@@ -116,7 +121,10 @@ export const ProjectFileSchema = ProjectFileV1Schema;
export type Project = z.infer<typeof ProjectSchema>; export type Project = z.infer<typeof ProjectSchema>;
export type Layer = z.infer<typeof LayerSchema>; export type Layer = z.infer<typeof LayerSchema>;
export type LayerType = z.infer<typeof LayerTypeSchema>; export type LayerType = z.infer<typeof LayerTypeSchema>;
export type LayerEffect = z.infer<typeof LayerEffectSchema>;
export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>; export type FaceBlurMethod = z.infer<typeof FaceBlurMethodSchema>;
export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>; export type FaceBlurRegion = z.infer<typeof FaceBlurRegionSchema>;
export type FaceBlurSettings = z.infer<typeof FaceBlurSettingsSchema>; export type FaceBlurEffect = z.infer<typeof FaceBlurEffectSchema>;
// Kept for compatibility with existing renderer code
export type FaceBlurSettings = Omit<FaceBlurEffect, "kind">;
export type ProjectFile = z.infer<typeof ProjectFileSchema>; export type ProjectFile = z.infer<typeof ProjectFileSchema>;