mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: transparency, clipboard paste, face detection optimization
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CanvasRenderer } from "./canvas-renderer";
|
||||||
|
import type { Layer } from "@pien-studio/types";
|
||||||
|
|
||||||
|
vi.mock("next/image", () => ({
|
||||||
|
default: ({ alt, src, unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => React.createElement("img", { alt, src, ...props }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/use-translations", () => ({
|
||||||
|
useTranslations: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CanvasRenderer", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
||||||
|
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", { configurable: true, value: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the rotation handle interactive", () => {
|
||||||
|
const layer: Layer = {
|
||||||
|
id: "layer-1",
|
||||||
|
type: "image",
|
||||||
|
sourceUri: "data:image/png;base64,test",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
};
|
||||||
|
const onRotateLayer = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<CanvasRenderer
|
||||||
|
layers={[layer]}
|
||||||
|
canvasWidth={200}
|
||||||
|
canvasHeight={200}
|
||||||
|
selectedLayerId={layer.id}
|
||||||
|
onSelectLayer={() => undefined}
|
||||||
|
onMoveLayer={() => undefined}
|
||||||
|
onResizeLayer={() => undefined}
|
||||||
|
onRotateLayer={onRotateLayer}
|
||||||
|
isDark={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rotateHandle = screen.getByTitle("editor.rotate");
|
||||||
|
expect(rotateHandle).toHaveClass("pointer-events-auto");
|
||||||
|
|
||||||
|
fireEvent(rotateHandle, new MouseEvent("pointerdown", { bubbles: true, button: 0, clientX: 50, clientY: 0 }));
|
||||||
|
fireEvent(rotateHandle, new MouseEvent("pointermove", { bubbles: true, clientX: 100, clientY: 50 }));
|
||||||
|
|
||||||
|
expect(onRotateLayer).toHaveBeenCalledWith(layer.id, 90);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,9 +3,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
import { RotateCw } from "lucide-react";
|
import { RotateCw } from "lucide-react";
|
||||||
import { CANVAS_HANDLE_BASE_SIZE, CANVAS_ROTATE_HANDLE_BASE_SIZE } from "../lib/editor-constants";
|
import {
|
||||||
|
CANVAS_HANDLE_BASE_SIZE,
|
||||||
|
CANVAS_ROTATE_HANDLE_BASE_SIZE,
|
||||||
|
} from "../lib/editor-constants";
|
||||||
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
|
import { buildFaceLabelOverlays } from "../lib/canvas-geometry";
|
||||||
import { renderFaceBlurRegions } from "../lib/face-blur-renderer";
|
import { renderImageWithFaceBlur } from "../lib/face-blur-renderer";
|
||||||
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 type { FaceBlurMethod, Layer } from "@pien-studio/types";
|
||||||
@@ -27,7 +30,13 @@ interface CanvasRendererProps {
|
|||||||
onContextMenu?: (x: number, y: number) => void;
|
onContextMenu?: (x: number, y: number) => void;
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
tool?: "pointer" | "hand" | "face";
|
tool?: "pointer" | "hand" | "face";
|
||||||
faceDetections?: { x: number; y: number; width: number; height: number; label?: string }[];
|
faceDetections?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
label?: string;
|
||||||
|
}[];
|
||||||
faceOverlayLayerId?: string | null;
|
faceOverlayLayerId?: string | null;
|
||||||
faceBlurPreview?: {
|
faceBlurPreview?: {
|
||||||
layerId: string;
|
layerId: string;
|
||||||
@@ -49,7 +58,13 @@ function BlurredImageLayer({
|
|||||||
faceBlurOverride?: {
|
faceBlurOverride?: {
|
||||||
method: FaceBlurMethod;
|
method: FaceBlurMethod;
|
||||||
amount: number;
|
amount: number;
|
||||||
regions: { x: number; y: number; width: number; height: number; censorColor?: string }[];
|
regions: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
censorColor?: string;
|
||||||
|
}[];
|
||||||
censorColor?: string;
|
censorColor?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -65,10 +80,8 @@ function BlurredImageLayer({
|
|||||||
const cw = canvas.width;
|
const cw = canvas.width;
|
||||||
const ch = canvas.height;
|
const ch = canvas.height;
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
ctx.clearRect(0, 0, cw, ch);
|
||||||
ctx.drawImage(image, 0, 0, cw, ch);
|
|
||||||
const blur = faceBlurOverride ?? layer.faceBlur;
|
const blur = faceBlurOverride ?? layer.faceBlur;
|
||||||
if (!blur || blur.regions.length === 0) return;
|
renderImageWithFaceBlur(ctx, image, blur, cw, ch);
|
||||||
renderFaceBlurRegions(ctx, image, blur, cw, ch);
|
|
||||||
}, [faceBlurOverride, layer.faceBlur]);
|
}, [faceBlurOverride, layer.faceBlur]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -91,7 +104,14 @@ function BlurredImageLayer({
|
|||||||
draw();
|
draw();
|
||||||
}, [draw, width, height]);
|
}, [draw, width, height]);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} width={Math.max(1, Math.round(width))} height={Math.max(1, Math.round(height))} className="pointer-events-none h-full w-full rounded object-cover" />;
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={Math.max(1, Math.round(width))}
|
||||||
|
height={Math.max(1, Math.round(height))}
|
||||||
|
className="pointer-events-none h-full w-full rounded object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasRenderer({
|
export function CanvasRenderer({
|
||||||
@@ -147,15 +167,35 @@ export function CanvasRenderer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const faceLabelOverlays = React.useMemo(() => {
|
const faceLabelOverlays = React.useMemo(() => {
|
||||||
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0) return [];
|
if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0)
|
||||||
return buildFaceLabelOverlays(layers, faceOverlayLayerId, faceDetections, viewport);
|
return [];
|
||||||
}, [faceDetections, faceOverlayLayerId, layers, tool, viewport.scale, viewport.x, viewport.y]);
|
return buildFaceLabelOverlays(
|
||||||
|
layers,
|
||||||
|
faceOverlayLayerId,
|
||||||
|
faceDetections,
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
faceDetections,
|
||||||
|
faceOverlayLayerId,
|
||||||
|
layers,
|
||||||
|
tool,
|
||||||
|
viewport.scale,
|
||||||
|
viewport.x,
|
||||||
|
viewport.y,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative overflow-hidden"
|
className="relative overflow-hidden"
|
||||||
style={{ width: "100%", height: "100%", touchAction: "none", userSelect: "none", cursor: tool === "hand" || isSpacePan ? "grab" : "default" }}
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
touchAction: "none",
|
||||||
|
userSelect: "none",
|
||||||
|
cursor: tool === "hand" || isSpacePan ? "grab" : "default",
|
||||||
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
|
if (tool === "pointer" && e.button === 0) onSelectLayer(null);
|
||||||
onContainerPointerDown(e);
|
onContainerPointerDown(e);
|
||||||
@@ -183,18 +223,26 @@ export function CanvasRenderer({
|
|||||||
willChange: "transform",
|
willChange: "transform",
|
||||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||||||
transformOrigin: "0 0",
|
transformOrigin: "0 0",
|
||||||
boxShadow: "0 0 0 1px var(--color-accent-strong)",
|
outline: `${2 / viewport.scale}px solid var(--color-accent-strong)`,
|
||||||
background: isDark ? "#17181b" : "#ffffff",
|
background:
|
||||||
|
"linear-gradient(45deg, #d0d0d0 25%, transparent 25%), linear-gradient(-45deg, #d0d0d0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d0d0d0 75%), linear-gradient(-45deg, transparent 75%, #d0d0d0 75%)",
|
||||||
|
backgroundSize: "16px 16px",
|
||||||
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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 === "image";
|
||||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
const layerWidth =
|
||||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
layer.width ??
|
||||||
|
(isImage ? Math.round(200 * layer.scale) : undefined);
|
||||||
|
const layerHeight =
|
||||||
|
layer.height ??
|
||||||
|
(isImage ? Math.round(150 * layer.scale) : undefined);
|
||||||
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
|
const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale;
|
||||||
const handleSizePx = `${handleSize}px`;
|
const handleSizePx = `${handleSize}px`;
|
||||||
const largeHandleSize = CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale;
|
const largeHandleSize =
|
||||||
|
CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale;
|
||||||
const largeHandleSizePx = `${largeHandleSize}px`;
|
const largeHandleSizePx = `${largeHandleSize}px`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -209,8 +257,12 @@ export function CanvasRenderer({
|
|||||||
transform: `rotate(${layer.rotation}deg)`,
|
transform: `rotate(${layer.rotation}deg)`,
|
||||||
opacity: layer.opacity,
|
opacity: layer.opacity,
|
||||||
cursor: "move",
|
cursor: "move",
|
||||||
border: isSelected ? "2px solid var(--color-accent-strong)" : "1px dashed transparent",
|
border: isSelected
|
||||||
outline: isSelected ? "2px solid var(--color-accent-strong)" : "none",
|
? "2px solid var(--color-accent-strong)"
|
||||||
|
: "1px dashed transparent",
|
||||||
|
outline: isSelected
|
||||||
|
? "2px solid var(--color-accent-strong)"
|
||||||
|
: "none",
|
||||||
outlineOffset: "2px",
|
outlineOffset: "2px",
|
||||||
zIndex: idx,
|
zIndex: idx,
|
||||||
}}
|
}}
|
||||||
@@ -218,13 +270,19 @@ export function CanvasRenderer({
|
|||||||
onClick={() => onSelectLayer(layer.id)}
|
onClick={() => onSelectLayer(layer.id)}
|
||||||
>
|
>
|
||||||
{isImage && layer.sourceUri ? (
|
{isImage && layer.sourceUri ? (
|
||||||
(faceBlurPreview && faceBlurPreview.layerId === layer.id && faceBlurPreview.regions.length > 0) ||
|
(faceBlurPreview &&
|
||||||
|
faceBlurPreview.layerId === layer.id &&
|
||||||
|
faceBlurPreview.regions.length > 0) ||
|
||||||
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
|
(layer.faceBlur && layer.faceBlur.regions.length > 0) ? (
|
||||||
<BlurredImageLayer
|
<BlurredImageLayer
|
||||||
layer={layer}
|
layer={layer}
|
||||||
width={layerWidth ?? 1}
|
width={layerWidth ?? 1}
|
||||||
height={layerHeight ?? 1}
|
height={layerHeight ?? 1}
|
||||||
faceBlurOverride={faceBlurPreview && faceBlurPreview.layerId === layer.id ? faceBlurPreview : null}
|
faceBlurOverride={
|
||||||
|
faceBlurPreview && faceBlurPreview.layerId === layer.id
|
||||||
|
? faceBlurPreview
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NextImage
|
<NextImage
|
||||||
@@ -271,41 +329,85 @@ export function CanvasRenderer({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||||
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, left: -handleSize / 2 }}
|
style={{
|
||||||
|
width: handleSizePx,
|
||||||
|
height: handleSizePx,
|
||||||
|
top: -handleSize / 2,
|
||||||
|
left: -handleSize / 2,
|
||||||
|
}}
|
||||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "tl")}
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "tl")}
|
||||||
title={t("editor.resize")}
|
title={t("editor.resize")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||||
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, right: -handleSize / 2 }}
|
style={{
|
||||||
|
width: handleSizePx,
|
||||||
|
height: handleSizePx,
|
||||||
|
top: -handleSize / 2,
|
||||||
|
right: -handleSize / 2,
|
||||||
|
}}
|
||||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "tr")}
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "tr")}
|
||||||
title={t("editor.resize")}
|
title={t("editor.resize")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||||
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, left: -handleSize / 2 }}
|
style={{
|
||||||
|
width: handleSizePx,
|
||||||
|
height: handleSizePx,
|
||||||
|
bottom: -handleSize / 2,
|
||||||
|
left: -handleSize / 2,
|
||||||
|
}}
|
||||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "bl")}
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "bl")}
|
||||||
title={t("editor.resize")}
|
title={t("editor.resize")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
|
||||||
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, right: -handleSize / 2 }}
|
style={{
|
||||||
|
width: handleSizePx,
|
||||||
|
height: handleSizePx,
|
||||||
|
bottom: -handleSize / 2,
|
||||||
|
right: -handleSize / 2,
|
||||||
|
}}
|
||||||
onPointerDown={(e) => onResizeHandleDown(e, layer, "br")}
|
onPointerDown={(e) => onResizeHandleDown(e, layer, "br")}
|
||||||
title={t("editor.resize")}
|
title={t("editor.resize")}
|
||||||
/>
|
/>
|
||||||
<div className="absolute pointer-events-none" style={{ top: -largeHandleSize, left: "50%", transform: "translateX(-50%)", height: largeHandleSize }}>
|
<div
|
||||||
<div className="w-px bg-[var(--color-accent-strong)]" style={{ width: "1px", height: "100%", marginLeft: "0px" }} />
|
className="absolute pointer-events-none"
|
||||||
|
style={{
|
||||||
|
top: -largeHandleSize,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
height: largeHandleSize,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-px bg-[var(--color-accent-strong)]"
|
||||||
|
style={{
|
||||||
|
width: "1px",
|
||||||
|
height: "100%",
|
||||||
|
marginLeft: "0px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
|
className="pointer-events-auto absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
|
||||||
style={{ width: largeHandleSizePx, height: largeHandleSizePx }}
|
style={{
|
||||||
|
width: largeHandleSizePx,
|
||||||
|
height: largeHandleSizePx,
|
||||||
|
}}
|
||||||
onPointerDown={(e) => onRotateHandleDown(e, layer)}
|
onPointerDown={(e) => onRotateHandleDown(e, layer)}
|
||||||
title={t("editor.rotate")}
|
title={t("editor.rotate")}
|
||||||
>
|
>
|
||||||
<RotateCw className="text-white" style={{ width: handleSize * 0.6, height: handleSize * 0.6 }} />
|
<RotateCw
|
||||||
|
className="text-white"
|
||||||
|
style={{
|
||||||
|
width: handleSize * 0.6,
|
||||||
|
height: handleSize * 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -176,12 +176,12 @@ function FileMenu({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
|
||||||
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
|
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
|
||||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||||
<button type="button" onClick={onExportPng} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportPng}</button>
|
<button type="button" onClick={onExportPng} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportPng}</button>
|
||||||
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</button>
|
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</button>
|
||||||
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
|
||||||
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
|
|
||||||
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
|
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type { EditorToolId } from "../store/editor-store";
|
import type { EditorToolId } from "../store/editor-store";
|
||||||
import { buildFacePreviews, loadImageFromUri, toFaceDetectionOverlays } from "../lib/face-detection-utils";
|
import {
|
||||||
|
buildFacePreviews,
|
||||||
|
loadImageFromUri,
|
||||||
|
toFaceDetectionOverlays,
|
||||||
|
} from "../lib/face-detection-utils";
|
||||||
|
|
||||||
export type FaceDetectionOverlay = {
|
export type FaceDetectionOverlay = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -34,21 +38,35 @@ type UseFaceDetectionOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
export function useFaceDetection(options: UseFaceDetectionOptions) {
|
||||||
const { tool, selectedLayerId, selectedImageLayer, activeLayerStillSelected } = options;
|
const {
|
||||||
|
tool,
|
||||||
|
selectedLayerId,
|
||||||
|
selectedImageLayer,
|
||||||
|
activeLayerStillSelected,
|
||||||
|
} = options;
|
||||||
const selectedImageLayerId = selectedImageLayer?.id ?? null;
|
const selectedImageLayerId = selectedImageLayer?.id ?? null;
|
||||||
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
|
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
|
||||||
const selectedImageWidth = selectedImageLayer?.width;
|
const selectedImageWidth = selectedImageLayer?.width;
|
||||||
const selectedImageHeight = selectedImageLayer?.height;
|
const selectedImageHeight = selectedImageLayer?.height;
|
||||||
const [faceDetections, setFaceDetections] = React.useState<FaceDetectionOverlay[]>([]);
|
const [faceDetections, setFaceDetections] = React.useState<
|
||||||
const [faceDetectionsLayerId, setFaceDetectionsLayerId] = React.useState<string | null>(null);
|
FaceDetectionOverlay[]
|
||||||
const [faceStatus, setFaceStatus] = React.useState<"idle" | "detecting" | "unsupported">("idle");
|
>([]);
|
||||||
|
const [faceDetectionsLayerId, setFaceDetectionsLayerId] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [faceStatus, setFaceStatus] = React.useState<
|
||||||
|
"idle" | "detecting" | "unsupported"
|
||||||
|
>("idle");
|
||||||
const [facePreviews, setFacePreviews] = React.useState<FacePreview[]>([]);
|
const [facePreviews, setFacePreviews] = React.useState<FacePreview[]>([]);
|
||||||
|
|
||||||
const resetFaceState = React.useCallback((status: "idle" | "detecting" | "unsupported" = "idle") => {
|
const resetFaceState = React.useCallback(
|
||||||
setFaceDetections((prev) => (prev.length === 0 ? prev : []));
|
(status: "idle" | "detecting" | "unsupported" = "idle") => {
|
||||||
setFaceDetectionsLayerId((prev) => (prev === null ? prev : null));
|
setFaceDetections((prev) => (prev.length === 0 ? prev : []));
|
||||||
setFaceStatus((prev) => (prev === status ? prev : status));
|
setFaceDetectionsLayerId((prev) => (prev === null ? prev : null));
|
||||||
}, []);
|
setFaceStatus((prev) => (prev === status ? prev : status));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
@@ -60,21 +78,18 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFaceState("detecting");
|
|
||||||
const image = await loadImageFromUri(selectedImageSourceUri);
|
|
||||||
if (!image) {
|
|
||||||
if (!canceled) {
|
|
||||||
resetFaceState("idle");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
resetFaceState("detecting");
|
||||||
const { detectFaceBoxes } = await import("../lib/face-ml");
|
const { detectFaceBoxes } = await import("../lib/face-ml");
|
||||||
const faces = await detectFaceBoxes(image);
|
const result = await detectFaceBoxes(selectedImageSourceUri);
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
if (!activeLayerStillSelected(layerId) || tool !== "face") return;
|
if (!activeLayerStillSelected(layerId) || tool !== "face") return;
|
||||||
const overlays = toFaceDetectionOverlays(faces, image, selectedImageWidth, selectedImageHeight);
|
const overlays = toFaceDetectionOverlays(
|
||||||
|
result.faces,
|
||||||
|
result,
|
||||||
|
selectedImageWidth,
|
||||||
|
selectedImageHeight,
|
||||||
|
);
|
||||||
setFaceDetections(overlays);
|
setFaceDetections(overlays);
|
||||||
setFaceDetectionsLayerId(layerId);
|
setFaceDetectionsLayerId(layerId);
|
||||||
setFaceStatus((prev) => (prev === "idle" ? prev : "idle"));
|
setFaceStatus((prev) => (prev === "idle" ? prev : "idle"));
|
||||||
@@ -90,7 +105,15 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
|||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
}, [activeLayerStillSelected, resetFaceState, selectedImageHeight, selectedImageLayerId, selectedImageSourceUri, selectedImageWidth, tool]);
|
}, [
|
||||||
|
activeLayerStillSelected,
|
||||||
|
resetFaceState,
|
||||||
|
selectedImageHeight,
|
||||||
|
selectedImageLayerId,
|
||||||
|
selectedImageSourceUri,
|
||||||
|
selectedImageWidth,
|
||||||
|
tool,
|
||||||
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (tool !== "face") {
|
if (tool !== "face") {
|
||||||
@@ -108,7 +131,11 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
|||||||
let canceled = false;
|
let canceled = false;
|
||||||
|
|
||||||
async function generateFacePreviews() {
|
async function generateFacePreviews() {
|
||||||
if (tool !== "face" || !selectedImageSourceUri || faceDetections.length === 0) {
|
if (
|
||||||
|
tool !== "face" ||
|
||||||
|
!selectedImageSourceUri ||
|
||||||
|
faceDetections.length === 0
|
||||||
|
) {
|
||||||
setFacePreviews([]);
|
setFacePreviews([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,7 +146,12 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previews = buildFacePreviews(image, faceDetections, selectedImageWidth, selectedImageHeight);
|
const previews = buildFacePreviews(
|
||||||
|
image,
|
||||||
|
faceDetections,
|
||||||
|
selectedImageWidth,
|
||||||
|
selectedImageHeight,
|
||||||
|
);
|
||||||
|
|
||||||
if (!canceled) setFacePreviews(previews);
|
if (!canceled) setFacePreviews(previews);
|
||||||
}
|
}
|
||||||
@@ -128,7 +160,13 @@ export function useFaceDetection(options: UseFaceDetectionOptions) {
|
|||||||
return () => {
|
return () => {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
};
|
};
|
||||||
}, [faceDetections, selectedImageHeight, selectedImageSourceUri, selectedImageWidth, tool]);
|
}, [
|
||||||
|
faceDetections,
|
||||||
|
selectedImageHeight,
|
||||||
|
selectedImageSourceUri,
|
||||||
|
selectedImageWidth,
|
||||||
|
tool,
|
||||||
|
]);
|
||||||
|
|
||||||
return { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus };
|
return { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus };
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-14
@@ -1,5 +1,5 @@
|
|||||||
import type { Layer, Project } from "@pien-studio/types";
|
import type { Layer, Project } from "@pien-studio/types";
|
||||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
import { renderImageWithFaceBlur } from "./face-blur-renderer";
|
||||||
|
|
||||||
type ExportOptions = {
|
type ExportOptions = {
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
@@ -20,7 +20,11 @@ function loadImage(src: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
function drawFallbackLayer(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
layer: Layer,
|
||||||
|
isDark: boolean,
|
||||||
|
) {
|
||||||
const text = layer.name ?? layer.type;
|
const 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);
|
||||||
@@ -45,15 +49,24 @@ function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark:
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
||||||
ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
ctx.font =
|
||||||
|
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
||||||
ctx.textAlign = "center";
|
ctx.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(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
async function drawLayer(
|
||||||
const width = layer.width ?? (layer.type === "image" ? Math.round(200 * layer.scale) : 120);
|
ctx: CanvasRenderingContext2D,
|
||||||
const height = layer.height ?? (layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
layer: Layer,
|
||||||
|
isDark: boolean,
|
||||||
|
) {
|
||||||
|
const width =
|
||||||
|
layer.width ??
|
||||||
|
(layer.type === "image" ? Math.round(200 * layer.scale) : 120);
|
||||||
|
const height =
|
||||||
|
layer.height ??
|
||||||
|
(layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = clampOpacity(layer.opacity);
|
ctx.globalAlpha = clampOpacity(layer.opacity);
|
||||||
@@ -64,10 +77,7 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
|
|||||||
if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) {
|
if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) {
|
||||||
try {
|
try {
|
||||||
const image = await loadImage(layer.sourceUri);
|
const image = await loadImage(layer.sourceUri);
|
||||||
ctx.drawImage(image, 0, 0, width, height);
|
renderImageWithFaceBlur(ctx, image, layer.faceBlur, width, height);
|
||||||
if (layer.faceBlur && layer.faceBlur.regions.length > 0) {
|
|
||||||
renderFaceBlurRegions(ctx, image, layer.faceBlur, width, height);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
drawFallbackLayer(ctx, layer, isDark);
|
drawFallbackLayer(ctx, layer, isDark);
|
||||||
}
|
}
|
||||||
@@ -78,8 +88,14 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
|
|||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportProjectAsPng(project: Project, options: ExportOptions) {
|
export async function exportProjectAsPng(
|
||||||
const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
|
project: Project,
|
||||||
|
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;
|
||||||
@@ -89,8 +105,6 @@ export async function exportProjectAsPng(project: Project, options: ExportOption
|
|||||||
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);
|
||||||
ctx.fillStyle = options.isDark ? "#17181b" : "#ffffff";
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
for (const layer of project.layers) {
|
for (const layer of project.layers) {
|
||||||
await drawLayer(ctx, layer, options.isDark);
|
await drawLayer(ctx, layer, options.isDark);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
import {
|
||||||
|
renderFaceBlurRegions,
|
||||||
|
renderImageWithFaceBlur,
|
||||||
|
} from "./face-blur-renderer";
|
||||||
|
|
||||||
function makeContext() {
|
function makeContext() {
|
||||||
return {
|
return {
|
||||||
@@ -27,7 +30,16 @@ describe("renderFaceBlurRegions", () => {
|
|||||||
{
|
{
|
||||||
method: "gaussian",
|
method: "gaussian",
|
||||||
amount: 24,
|
amount: 24,
|
||||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 120,
|
||||||
|
y: 80,
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
600,
|
600,
|
||||||
400,
|
400,
|
||||||
@@ -35,7 +47,17 @@ describe("renderFaceBlurRegions", () => {
|
|||||||
|
|
||||||
expect(ctx.save).toHaveBeenCalledOnce();
|
expect(ctx.save).toHaveBeenCalledOnce();
|
||||||
expect(ctx.filter).toBe("blur(24px)");
|
expect(ctx.filter).toBe("blur(24px)");
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100);
|
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||||
|
image,
|
||||||
|
120,
|
||||||
|
80,
|
||||||
|
300,
|
||||||
|
200,
|
||||||
|
60,
|
||||||
|
40,
|
||||||
|
150,
|
||||||
|
100,
|
||||||
|
);
|
||||||
expect(ctx.restore).toHaveBeenCalledOnce();
|
expect(ctx.restore).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,13 +68,22 @@ describe("renderFaceBlurRegions", () => {
|
|||||||
const ctx = makeContext();
|
const ctx = makeContext();
|
||||||
const image = makeImage();
|
const image = makeImage();
|
||||||
const sampleDrawImage = vi.fn();
|
const sampleDrawImage = vi.fn();
|
||||||
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
|
const sampleCtx = {
|
||||||
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
|
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 nativeCreateElement = doc.createElement.bind(doc);
|
||||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
const createElement = vi
|
||||||
if (tagName === "canvas") return sampleCanvas;
|
.spyOn(doc, "createElement")
|
||||||
return nativeCreateElement(tagName);
|
.mockImplementation((tagName: string) => {
|
||||||
});
|
if (tagName === "canvas") return sampleCanvas;
|
||||||
|
return nativeCreateElement(tagName);
|
||||||
|
});
|
||||||
|
|
||||||
renderFaceBlurRegions(
|
renderFaceBlurRegions(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -60,7 +91,16 @@ describe("renderFaceBlurRegions", () => {
|
|||||||
{
|
{
|
||||||
method: "pixelate",
|
method: "pixelate",
|
||||||
amount: 10,
|
amount: 10,
|
||||||
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 200,
|
||||||
|
y: 100,
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
600,
|
600,
|
||||||
400,
|
400,
|
||||||
@@ -68,8 +108,28 @@ describe("renderFaceBlurRegions", () => {
|
|||||||
|
|
||||||
expect(sampleCanvas.width).toBe(16);
|
expect(sampleCanvas.width).toBe(16);
|
||||||
expect(sampleCanvas.height).toBe(12);
|
expect(sampleCanvas.height).toBe(12);
|
||||||
expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12);
|
expect(sampleDrawImage).toHaveBeenCalledWith(
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60);
|
image,
|
||||||
|
200,
|
||||||
|
100,
|
||||||
|
160,
|
||||||
|
120,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||||
|
sampleCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
12,
|
||||||
|
100,
|
||||||
|
50,
|
||||||
|
80,
|
||||||
|
60,
|
||||||
|
);
|
||||||
createElement.mockRestore();
|
createElement.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +143,17 @@ describe("renderFaceBlurRegions", () => {
|
|||||||
method: "censor",
|
method: "censor",
|
||||||
amount: 20,
|
amount: 20,
|
||||||
censorColor: "#ff0000",
|
censorColor: "#ff0000",
|
||||||
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
|
regions: [
|
||||||
|
{
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 40,
|
||||||
|
height: 50,
|
||||||
|
sourceWidth: 1200,
|
||||||
|
sourceHeight: 800,
|
||||||
|
censorColor: "#00ff00",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
600,
|
600,
|
||||||
400,
|
400,
|
||||||
@@ -108,6 +178,178 @@ describe("renderFaceBlurRegions", () => {
|
|||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,16 +21,126 @@ 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(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height);
|
sampleCtx.drawImage(
|
||||||
|
source,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sampleCanvas.width,
|
||||||
|
sampleCanvas.height,
|
||||||
|
);
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
ctx.drawImage(
|
||||||
|
sampleCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sampleCanvas.width,
|
||||||
|
sampleCanvas.height,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
);
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseCanvasFilter(ctx: CanvasRenderingContext2D) {
|
||||||
|
return "filter" in ctx && typeof ctx.filter === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBlurredRegionFallback(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
source: CanvasImageSource,
|
||||||
|
sourceX: number,
|
||||||
|
sourceY: number,
|
||||||
|
sourceWidth: number,
|
||||||
|
sourceHeight: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
targetWidth: number,
|
||||||
|
targetHeight: number,
|
||||||
|
amount: number,
|
||||||
|
) {
|
||||||
|
const regionCanvas = document.createElement("canvas");
|
||||||
|
regionCanvas.width = Math.max(1, Math.round(targetWidth));
|
||||||
|
regionCanvas.height = Math.max(1, Math.round(targetHeight));
|
||||||
|
const regionCtx = regionCanvas.getContext("2d");
|
||||||
|
if (!regionCtx) return;
|
||||||
|
|
||||||
|
regionCtx.drawImage(
|
||||||
|
source,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
sourceWidth,
|
||||||
|
sourceHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scale = Math.max(0.04, Math.min(0.5, 1 / Math.max(2, amount / 2)));
|
||||||
|
const blurCanvas = document.createElement("canvas");
|
||||||
|
blurCanvas.width = Math.max(1, Math.round(regionCanvas.width * scale));
|
||||||
|
blurCanvas.height = Math.max(1, Math.round(regionCanvas.height * scale));
|
||||||
|
const blurCtx = blurCanvas.getContext("2d");
|
||||||
|
if (!blurCtx) return;
|
||||||
|
|
||||||
|
blurCtx.imageSmoothingEnabled = true;
|
||||||
|
blurCtx.drawImage(regionCanvas, 0, 0, blurCanvas.width, blurCanvas.height);
|
||||||
|
regionCtx.imageSmoothingEnabled = true;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
regionCtx.clearRect(0, 0, regionCanvas.width, regionCanvas.height);
|
||||||
|
regionCtx.drawImage(
|
||||||
|
blurCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
blurCanvas.width,
|
||||||
|
blurCanvas.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
);
|
||||||
|
blurCtx.clearRect(0, 0, blurCanvas.width, blurCanvas.height);
|
||||||
|
blurCtx.drawImage(
|
||||||
|
regionCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
blurCanvas.width,
|
||||||
|
blurCanvas.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
regionCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
regionCanvas.width,
|
||||||
|
regionCanvas.height,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderFaceBlurRegions(
|
export function renderFaceBlurRegions(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
image: HTMLImageElement,
|
image: HTMLImageElement,
|
||||||
blur: { method: FaceBlurSettings["method"]; amount: number; regions: BlurRegion[]; censorColor?: string },
|
blur: {
|
||||||
|
method: FaceBlurSettings["method"];
|
||||||
|
amount: number;
|
||||||
|
regions: BlurRegion[];
|
||||||
|
censorColor?: string;
|
||||||
|
},
|
||||||
targetWidth: number,
|
targetWidth: number,
|
||||||
targetHeight: number,
|
targetHeight: number,
|
||||||
): void {
|
): void {
|
||||||
@@ -49,10 +159,18 @@ 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 ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX));
|
const sx0 = hasSourceDims
|
||||||
const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY));
|
? region.x
|
||||||
const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX));
|
: Math.max(0, Math.floor(region.x * legacyScaleX));
|
||||||
const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY));
|
const sy0 = hasSourceDims
|
||||||
|
? region.y
|
||||||
|
: Math.max(0, Math.floor(region.y * legacyScaleY));
|
||||||
|
const sw = hasSourceDims
|
||||||
|
? region.width
|
||||||
|
: Math.max(1, Math.floor(region.width * legacyScaleX));
|
||||||
|
const sh = hasSourceDims
|
||||||
|
? region.height
|
||||||
|
: Math.max(1, Math.floor(region.height * legacyScaleY));
|
||||||
|
|
||||||
if (blur.method === "censor") {
|
if (blur.method === "censor") {
|
||||||
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111";
|
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111";
|
||||||
@@ -66,9 +184,84 @@ export function renderFaceBlurRegions(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.save();
|
if (canUseCanvasFilter(ctx)) {
|
||||||
ctx.filter = `blur(${blur.amount}px)`;
|
ctx.save();
|
||||||
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
ctx.filter = `blur(${blur.amount}px)`;
|
||||||
ctx.restore();
|
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
||||||
|
ctx.restore();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBlurredRegionFallback(
|
||||||
|
ctx,
|
||||||
|
image,
|
||||||
|
sx0,
|
||||||
|
sy0,
|
||||||
|
sw,
|
||||||
|
sh,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
blur.amount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderImageWithFaceBlur(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
image: HTMLImageElement,
|
||||||
|
blur:
|
||||||
|
| {
|
||||||
|
method: FaceBlurSettings["method"];
|
||||||
|
amount: number;
|
||||||
|
regions: BlurRegion[];
|
||||||
|
censorColor?: string;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
targetWidth: number,
|
||||||
|
targetHeight: number,
|
||||||
|
): void {
|
||||||
|
if (!blur || blur.regions.length === 0) {
|
||||||
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBlurAtSourceSize = blur.regions.every(
|
||||||
|
(region) => (region.sourceWidth ?? 0) > 0 && (region.sourceHeight ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (!canBlurAtSourceSize) {
|
||||||
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||||
|
renderFaceBlurRegions(ctx, image, blur, targetWidth, targetHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCanvas = document.createElement("canvas");
|
||||||
|
sourceCanvas.width = Math.max(1, image.naturalWidth);
|
||||||
|
sourceCanvas.height = Math.max(1, image.naturalHeight);
|
||||||
|
const sourceCtx = sourceCanvas.getContext("2d");
|
||||||
|
if (!sourceCtx) {
|
||||||
|
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceCtx.drawImage(image, 0, 0, sourceCanvas.width, sourceCanvas.height);
|
||||||
|
renderFaceBlurRegions(
|
||||||
|
sourceCtx,
|
||||||
|
image,
|
||||||
|
blur,
|
||||||
|
sourceCanvas.width,
|
||||||
|
sourceCanvas.height,
|
||||||
|
);
|
||||||
|
ctx.drawImage(
|
||||||
|
sourceCanvas,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
sourceCanvas.width,
|
||||||
|
sourceCanvas.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
targetWidth,
|
||||||
|
targetHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { FaceDetectionOverlay, FacePreview } from "../hooks/use-face-detection";
|
import type {
|
||||||
|
FaceDetectionOverlay,
|
||||||
|
FacePreview,
|
||||||
|
} from "../hooks/use-face-detection";
|
||||||
|
|
||||||
export async function loadImageFromUri(uri: string): Promise<HTMLImageElement | null> {
|
export async function loadImageFromUri(
|
||||||
|
uri: string,
|
||||||
|
): Promise<HTMLImageElement | null> {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.crossOrigin = "anonymous";
|
image.crossOrigin = "anonymous";
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -13,8 +18,15 @@ export async function loadImageFromUri(uri: string): Promise<HTMLImageElement |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toFaceDetectionOverlays(
|
export function toFaceDetectionOverlays(
|
||||||
faces: Array<{ x: number; y: number; width: number; height: number; gender?: string; genderScore?: number }>,
|
faces: Array<{
|
||||||
image: HTMLImageElement,
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
gender?: string;
|
||||||
|
genderScore?: number;
|
||||||
|
}>,
|
||||||
|
image: { naturalWidth: number; naturalHeight: number },
|
||||||
layerWidth?: number,
|
layerWidth?: number,
|
||||||
layerHeight?: number,
|
layerHeight?: number,
|
||||||
): FaceDetectionOverlay[] {
|
): FaceDetectionOverlay[] {
|
||||||
@@ -25,8 +37,11 @@ export function toFaceDetectionOverlays(
|
|||||||
|
|
||||||
return faces.map((face, index) => {
|
return faces.map((face, index) => {
|
||||||
const genderLabel = face.gender ?? "unknown";
|
const genderLabel = face.gender ?? "unknown";
|
||||||
const scoreLabel = face.genderScore != null ? `${Math.round(face.genderScore * 100)}%` : "";
|
const scoreLabel =
|
||||||
const label = scoreLabel ? `Person ${index + 1} - ${genderLabel} ${scoreLabel}` : `Person ${index + 1} - ${genderLabel}`;
|
face.genderScore != null ? `${Math.round(face.genderScore * 100)}%` : "";
|
||||||
|
const label = scoreLabel
|
||||||
|
? `Person ${index + 1} - ${genderLabel} ${scoreLabel}`
|
||||||
|
: `Person ${index + 1} - ${genderLabel}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: face.x * scaleX,
|
x: face.x * scaleX,
|
||||||
@@ -71,7 +86,10 @@ export function buildFacePreviews(
|
|||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return { id: `face-${index + 1}`, src: "" };
|
if (!ctx) return { id: `face-${index + 1}`, src: "" };
|
||||||
ctx.drawImage(image, sx, sy, cw, ch, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(image, sx, sy, cw, ch, 0, 0, canvas.width, canvas.height);
|
||||||
return { id: `face-${index + 1}`, src: canvas.toDataURL("image/jpeg", 0.9) };
|
return {
|
||||||
|
id: `face-${index + 1}`,
|
||||||
|
src: canvas.toDataURL("image/jpeg", 0.9),
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.filter((preview) => preview.src);
|
.filter((preview) => preview.src);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { FaceBox, FaceDetectionResult } from "./face-ml";
|
||||||
|
|
||||||
|
type FaceDetectionWorkerRequest = {
|
||||||
|
id: number;
|
||||||
|
sourceUri: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FaceDetectionWorkerResponse = {
|
||||||
|
id: number;
|
||||||
|
result?: FaceDetectionResult;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TinyFaceOptions = { inputSize: number; scoreThreshold: number };
|
||||||
|
type FaceApiDet = {
|
||||||
|
gender: string;
|
||||||
|
genderProbability: number;
|
||||||
|
detection: { box: { x: number; y: number; width: number; height: number } };
|
||||||
|
};
|
||||||
|
type FaceApiRuntime = {
|
||||||
|
tf: {
|
||||||
|
browser: {
|
||||||
|
fromPixels: (pixels: ImageData) => Tensor3DLike;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
faceapi: {
|
||||||
|
detectAllFaces: (
|
||||||
|
img: unknown,
|
||||||
|
options: TinyFaceOptions,
|
||||||
|
) => {
|
||||||
|
withFaceLandmarks: (useTinyLandmarkNet: boolean) => {
|
||||||
|
withAgeAndGender: () => Promise<FaceApiDet[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
TinyFaceDetectorOptions: new (options: TinyFaceOptions) => TinyFaceOptions;
|
||||||
|
};
|
||||||
|
type Tensor3DLike = {
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let faceApiModelsPromise: Promise<FaceApiRuntime> | null = null;
|
||||||
|
|
||||||
|
self.onmessage = async (event: MessageEvent<FaceDetectionWorkerRequest>) => {
|
||||||
|
const { id, sourceUri } = event.data;
|
||||||
|
let bitmap: ImageBitmap | null = null;
|
||||||
|
let tensor: Tensor3DLike | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = await loadFaceApiModels();
|
||||||
|
bitmap = await loadImageBitmap(sourceUri);
|
||||||
|
const imageData = await imageBitmapToImageData(bitmap);
|
||||||
|
tensor = runtime.tf.browser.fromPixels(imageData);
|
||||||
|
const faces = await runDetectorPasses(
|
||||||
|
tensor,
|
||||||
|
runtime.faceapi,
|
||||||
|
runtime.TinyFaceDetectorOptions,
|
||||||
|
);
|
||||||
|
const result = {
|
||||||
|
faces,
|
||||||
|
naturalWidth: bitmap.width,
|
||||||
|
naturalHeight: bitmap.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.postMessage({ id, result } satisfies FaceDetectionWorkerResponse);
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({
|
||||||
|
id,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
} satisfies FaceDetectionWorkerResponse);
|
||||||
|
} finally {
|
||||||
|
tensor?.dispose();
|
||||||
|
bitmap?.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadFaceApiModels(): Promise<FaceApiRuntime> {
|
||||||
|
if (faceApiModelsPromise) return faceApiModelsPromise;
|
||||||
|
|
||||||
|
faceApiModelsPromise = (async () => {
|
||||||
|
const tf = await import("@tensorflow/tfjs");
|
||||||
|
await tf.ready();
|
||||||
|
|
||||||
|
const faceapi = await import("@vladmandic/face-api");
|
||||||
|
faceapi.env.setEnv({
|
||||||
|
Canvas: OffscreenCanvas,
|
||||||
|
CanvasRenderingContext2D: OffscreenCanvasRenderingContext2D,
|
||||||
|
Image: class WorkerImage {},
|
||||||
|
ImageData,
|
||||||
|
Video: class WorkerVideo {},
|
||||||
|
createCanvasElement: () => new OffscreenCanvas(1, 1),
|
||||||
|
createImageElement: () => {
|
||||||
|
throw new Error(
|
||||||
|
"HTMLImageElement is unavailable in face detection worker",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createVideoElement: () => {
|
||||||
|
throw new Error(
|
||||||
|
"HTMLVideoElement is unavailable in face detection worker",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fetch,
|
||||||
|
readFile: () => {
|
||||||
|
throw new Error("readFile is unavailable in face detection worker");
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof faceapi.env.setEnv>[0]);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
faceapi.nets.tinyFaceDetector.loadFromUri(
|
||||||
|
"https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights",
|
||||||
|
),
|
||||||
|
faceapi.nets.faceLandmark68TinyNet.loadFromUri(
|
||||||
|
"https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights",
|
||||||
|
),
|
||||||
|
faceapi.nets.ageGenderNet.loadFromUri(
|
||||||
|
"https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TinyFaceDetectorOptions = (
|
||||||
|
faceapi as unknown as {
|
||||||
|
TinyFaceDetectorOptions: new (
|
||||||
|
options: TinyFaceOptions,
|
||||||
|
) => TinyFaceOptions;
|
||||||
|
}
|
||||||
|
).TinyFaceDetectorOptions;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tf,
|
||||||
|
faceapi,
|
||||||
|
TinyFaceDetectorOptions,
|
||||||
|
} as unknown as FaceApiRuntime;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return faceApiModelsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImageBitmap(sourceUri: string): Promise<ImageBitmap> {
|
||||||
|
if (typeof createImageBitmap === "undefined") {
|
||||||
|
throw new Error("createImageBitmap is unavailable in this browser worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(sourceUri);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load image for face detection: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createImageBitmap(await response.blob());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageBitmapToImageData(bitmap: ImageBitmap): Promise<ImageData> {
|
||||||
|
if (typeof OffscreenCanvas === "undefined") {
|
||||||
|
throw new Error("OffscreenCanvas is unavailable in this browser worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||||
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error("Could not prepare image for face detection");
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDetectorPasses(
|
||||||
|
image: Tensor3DLike,
|
||||||
|
faceapi: FaceApiRuntime["faceapi"],
|
||||||
|
TinyFaceDetectorOptions: FaceApiRuntime["TinyFaceDetectorOptions"],
|
||||||
|
): Promise<FaceBox[]> {
|
||||||
|
const passes = [
|
||||||
|
{ inputSize: 320, scoreThreshold: 0.5 },
|
||||||
|
{ inputSize: 416, scoreThreshold: 0.45 },
|
||||||
|
{ inputSize: 512, scoreThreshold: 0.5 },
|
||||||
|
{ inputSize: 608, scoreThreshold: 0.45 },
|
||||||
|
{ inputSize: 736, scoreThreshold: 0.4 },
|
||||||
|
{ inputSize: 864, scoreThreshold: 0.35 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allDets = await Promise.all(
|
||||||
|
passes.map(({ inputSize, scoreThreshold }) =>
|
||||||
|
faceapi
|
||||||
|
.detectAllFaces(
|
||||||
|
image,
|
||||||
|
new TinyFaceDetectorOptions({ inputSize, scoreThreshold }),
|
||||||
|
)
|
||||||
|
.withFaceLandmarks(true)
|
||||||
|
.withAgeAndGender(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flat = allDets.flat();
|
||||||
|
if (flat.length === 0) return [];
|
||||||
|
|
||||||
|
function iou(
|
||||||
|
a: { x: number; y: number; width: number; height: number },
|
||||||
|
b: { x: number; y: number; width: number; height: number },
|
||||||
|
) {
|
||||||
|
const ix = Math.max(a.x, b.x);
|
||||||
|
const iy = Math.max(a.y, b.y);
|
||||||
|
const ix2 = Math.min(a.x + a.width, b.x + b.width);
|
||||||
|
const iy2 = Math.min(a.y + a.height, b.y + b.height);
|
||||||
|
const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
|
||||||
|
const union = a.width * a.height + b.width * b.height - inter;
|
||||||
|
return union > 0 ? inter / union : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function avgGender(dets: FaceApiDet[]): {
|
||||||
|
gender: "male" | "female" | undefined;
|
||||||
|
score: number;
|
||||||
|
} {
|
||||||
|
let maleScore = 0;
|
||||||
|
let femaleScore = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const det of dets) {
|
||||||
|
if (det.gender === "male") maleScore += det.genderProbability;
|
||||||
|
else if (det.gender === "female") femaleScore += det.genderProbability;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (count === 0) return { gender: undefined, score: 0 };
|
||||||
|
const avgMale = maleScore / count;
|
||||||
|
const avgFemale = femaleScore / count;
|
||||||
|
if (avgMale > avgFemale) return { gender: "male", score: avgMale };
|
||||||
|
if (avgFemale > avgMale) return { gender: "female", score: avgFemale };
|
||||||
|
return { gender: undefined, score: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters: FaceApiDet[][] = [];
|
||||||
|
for (const det of flat) {
|
||||||
|
const b = det.detection?.box;
|
||||||
|
if (!b) continue;
|
||||||
|
let matched = false;
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
if (cluster.some((c) => iou(c.detection.box, b) > 0.4)) {
|
||||||
|
cluster.push(det);
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matched) clusters.push([det]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters.map((group) => {
|
||||||
|
const largest = [...group].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.detection?.box?.width ?? 0) * (b.detection?.box?.height ?? 0) -
|
||||||
|
(a.detection?.box?.width ?? 0) * (a.detection?.box?.height ?? 0),
|
||||||
|
)[0];
|
||||||
|
const box = largest.detection.box;
|
||||||
|
const { gender, score: genderScore } = avgGender(group);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: box.x,
|
||||||
|
y: box.y,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
gender,
|
||||||
|
genderScore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
+86
-102
@@ -8,116 +8,100 @@ export type FaceBox = {
|
|||||||
score?: number;
|
score?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[]> {
|
export type FaceDetectionResult = {
|
||||||
const tf = await import("@tensorflow/tfjs");
|
faces: FaceBox[];
|
||||||
await tf.ready();
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
const faceapi = await import("@vladmandic/face-api");
|
type FaceDetectionWorkerRequest = {
|
||||||
await Promise.all([
|
id: number;
|
||||||
faceapi.nets.tinyFaceDetector.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
sourceUri: string;
|
||||||
faceapi.nets.faceLandmark68TinyNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
};
|
||||||
faceapi.nets.ageGenderNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
type TinyFaceOptions = { inputSize: number; scoreThreshold: number };
|
type FaceDetectionWorkerResponse = {
|
||||||
const TinyFaceDetectorOptions = (
|
id: number;
|
||||||
faceapi as unknown as { TinyFaceDetectorOptions: new (options: TinyFaceOptions) => TinyFaceOptions }
|
result?: FaceDetectionResult;
|
||||||
).TinyFaceDetectorOptions;
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const passes = [
|
let faceDetectionWorker: Worker | null = null;
|
||||||
{ inputSize: 320, scoreThreshold: 0.5 },
|
let faceDetectionWorkerDisabled = false;
|
||||||
{ inputSize: 416, scoreThreshold: 0.45 },
|
let workerRequestId = 0;
|
||||||
{ inputSize: 512, scoreThreshold: 0.5 },
|
const workerRequests = new Map<
|
||||||
{ inputSize: 608, scoreThreshold: 0.45 },
|
number,
|
||||||
{ inputSize: 736, scoreThreshold: 0.4 },
|
{
|
||||||
{ inputSize: 864, scoreThreshold: 0.35 },
|
resolve: (result: FaceDetectionResult) => void;
|
||||||
];
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
type FaceApiDet = {
|
export async function detectFaceBoxes(
|
||||||
gender: string;
|
sourceUri: string,
|
||||||
genderProbability: number;
|
): Promise<FaceDetectionResult> {
|
||||||
detection: { box: { x: number; y: number; width: number; height: number } };
|
if (typeof Worker === "undefined" || faceDetectionWorkerDisabled) {
|
||||||
};
|
throw new Error("Face detection worker is unavailable");
|
||||||
|
|
||||||
const allDets = await Promise.all(
|
|
||||||
passes.map(({ inputSize, scoreThreshold }) =>
|
|
||||||
(faceapi as unknown as {
|
|
||||||
detectAllFaces: (
|
|
||||||
img: HTMLImageElement,
|
|
||||||
options: TinyFaceOptions
|
|
||||||
) => {
|
|
||||||
withFaceLandmarks: (useTinyLandmarkNet: boolean) => {
|
|
||||||
withAgeAndGender: () => Promise<FaceApiDet[]>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.detectAllFaces(image, new TinyFaceDetectorOptions({ inputSize, scoreThreshold }))
|
|
||||||
.withFaceLandmarks(true)
|
|
||||||
.withAgeAndGender()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const flat = allDets.flat();
|
|
||||||
if (flat.length === 0) return [];
|
|
||||||
|
|
||||||
function iou(a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }) {
|
|
||||||
const ix = Math.max(a.x, b.x);
|
|
||||||
const iy = Math.max(a.y, b.y);
|
|
||||||
const ix2 = Math.min(a.x + a.width, b.x + b.width);
|
|
||||||
const iy2 = Math.min(a.y + a.height, b.y + b.height);
|
|
||||||
const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy);
|
|
||||||
const union = a.width * a.height + b.width * b.height - inter;
|
|
||||||
return union > 0 ? inter / union : 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function avgGender(dets: FaceApiDet[]): { gender: "male" | "female" | undefined; score: number } {
|
try {
|
||||||
let maleScore = 0;
|
return await detectFaceBoxesInWorker(sourceUri);
|
||||||
let femaleScore = 0;
|
} catch (err) {
|
||||||
let count = 0;
|
faceDetectionWorkerDisabled = true;
|
||||||
for (const det of dets) {
|
faceDetectionWorker?.terminate();
|
||||||
if (det.gender === "male") maleScore += det.genderProbability;
|
faceDetectionWorker = null;
|
||||||
else if (det.gender === "female") femaleScore += det.genderProbability;
|
console.warn("[face-detection] worker failed:", err);
|
||||||
count++;
|
throw err;
|
||||||
}
|
|
||||||
if (count === 0) return { gender: undefined, score: 0 };
|
|
||||||
const avgMale = maleScore / count;
|
|
||||||
const avgFemale = femaleScore / count;
|
|
||||||
if (avgMale > avgFemale) return { gender: "male", score: avgMale };
|
|
||||||
if (avgFemale > avgMale) return { gender: "female", score: avgFemale };
|
|
||||||
return { gender: undefined, score: 0 };
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const clusters: FaceApiDet[][] = [];
|
function detectFaceBoxesInWorker(
|
||||||
for (const det of flat) {
|
sourceUri: string,
|
||||||
const b = det.detection?.box;
|
): Promise<FaceDetectionResult> {
|
||||||
if (!b) continue;
|
const worker = getFaceDetectionWorker();
|
||||||
let matched = false;
|
const id = ++workerRequestId;
|
||||||
for (const cluster of clusters) {
|
|
||||||
if (cluster.some((c) => iou(c.detection.box, b) > 0.4)) {
|
|
||||||
cluster.push(det);
|
|
||||||
matched = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!matched) clusters.push([det]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return clusters.map((group) => {
|
return new Promise<FaceDetectionResult>((resolve, reject) => {
|
||||||
const largest = [...group].sort(
|
workerRequests.set(id, { resolve, reject });
|
||||||
(a, b) =>
|
worker.postMessage({ id, sourceUri } satisfies FaceDetectionWorkerRequest);
|
||||||
(b.detection?.box?.width ?? 0) * (b.detection?.box?.height ?? 0) -
|
|
||||||
(a.detection?.box?.width ?? 0) * (a.detection?.box?.height ?? 0)
|
|
||||||
)[0];
|
|
||||||
const box = largest.detection.box;
|
|
||||||
const { gender, score: genderScore } = avgGender(group);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: box.x,
|
|
||||||
y: box.y,
|
|
||||||
width: box.width,
|
|
||||||
height: box.height,
|
|
||||||
gender,
|
|
||||||
genderScore,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFaceDetectionWorker(): Worker {
|
||||||
|
if (faceDetectionWorker) return faceDetectionWorker;
|
||||||
|
|
||||||
|
faceDetectionWorker = new Worker(
|
||||||
|
new URL("./face-detection-worker.ts", import.meta.url),
|
||||||
|
{ type: "module" },
|
||||||
|
);
|
||||||
|
faceDetectionWorker.onmessage = (
|
||||||
|
event: MessageEvent<FaceDetectionWorkerResponse>,
|
||||||
|
) => {
|
||||||
|
const request = workerRequests.get(event.data.id);
|
||||||
|
if (!request) return;
|
||||||
|
workerRequests.delete(event.data.id);
|
||||||
|
if (event.data.error) {
|
||||||
|
faceDetectionWorkerDisabled = true;
|
||||||
|
faceDetectionWorker?.terminate();
|
||||||
|
faceDetectionWorker = null;
|
||||||
|
request.reject(new Error(event.data.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.data.result) {
|
||||||
|
request.reject(new Error("Face detection worker returned no result"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.resolve(event.data.result);
|
||||||
|
};
|
||||||
|
faceDetectionWorker.onerror = (event) => {
|
||||||
|
const error = new Error(event.message || "Face detection worker failed");
|
||||||
|
faceDetectionWorkerDisabled = true;
|
||||||
|
workerRequests.forEach((request) => request.reject(error));
|
||||||
|
workerRequests.clear();
|
||||||
|
faceDetectionWorker?.terminate();
|
||||||
|
faceDetectionWorker = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return faceDetectionWorker;
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@
|
|||||||
"ui": {
|
"ui": {
|
||||||
"locale": "Locale",
|
"locale": "Locale",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
"lightMode": "Light mode"
|
"lightMode": "Light mode",
|
||||||
|
"systemMode": "System"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@
|
|||||||
"ui": {
|
"ui": {
|
||||||
"locale": "言語",
|
"locale": "言語",
|
||||||
"darkMode": "ダークモード",
|
"darkMode": "ダークモード",
|
||||||
"lightMode": "ライトモード"
|
"lightMode": "ライトモード",
|
||||||
|
"systemMode": "システム"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@
|
|||||||
"ui": {
|
"ui": {
|
||||||
"locale": "ภาษา",
|
"locale": "ภาษา",
|
||||||
"darkMode": "โหมดมืด",
|
"darkMode": "โหมดมืด",
|
||||||
"lightMode": "โหมดสว่าง"
|
"lightMode": "โหมดสว่าง",
|
||||||
|
"systemMode": "ระบบ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -284,14 +284,68 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
|
return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
pasteLayer: () =>
|
pasteLayer: () => {
|
||||||
set((state) => {
|
const state = get();
|
||||||
if (!state.clipboardLayer) return state;
|
if (!state.clipboardLayer) {
|
||||||
|
navigator.clipboard.read().then(async (clipboardItems) => {
|
||||||
|
for (const item of clipboardItems) {
|
||||||
|
for (const type of item.types) {
|
||||||
|
if (type.startsWith("image/")) {
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
const reader = new FileReader();
|
||||||
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
|
||||||
|
image.onerror = () =>
|
||||||
|
resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight });
|
||||||
|
image.src = dataUrl;
|
||||||
|
});
|
||||||
|
const layer = createLayer("image", {
|
||||||
|
name: "Image",
|
||||||
|
sourceUri: dataUrl,
|
||||||
|
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||||
|
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||||
|
width: Math.max(1, Math.round(imageSize.width)),
|
||||||
|
height: Math.max(1, Math.round(imageSize.height)),
|
||||||
|
});
|
||||||
|
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navigator.clipboard.readText().then((text) => {
|
||||||
|
if (text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith("data:image") || trimmed.startsWith("http") || trimmed.startsWith("blob:")) {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
const layer = createLayer("image", {
|
||||||
|
name: "Image",
|
||||||
|
sourceUri: trimmed,
|
||||||
|
x: DEFAULT_IMAGE_IMPORT.offsetX,
|
||||||
|
y: DEFAULT_IMAGE_IMPORT.offsetY,
|
||||||
|
width: Math.max(1, Math.round(image.naturalWidth)),
|
||||||
|
height: Math.max(1, Math.round(image.naturalHeight)),
|
||||||
|
});
|
||||||
|
set((s) => withCommittedProject(s, addLayer(s.project, layer), { selectedLayerId: layer.id }));
|
||||||
|
};
|
||||||
|
image.src = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const base = state.clipboardLayer;
|
const base = state.clipboardLayer;
|
||||||
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 };
|
||||||
const nextProject = addLayer(state.project, pasted);
|
const nextProject = addLayer(state.project, pasted);
|
||||||
return withCommittedProject(state, nextProject, { selectedLayerId: pasted.id });
|
set((s) => withCommittedProject(s, nextProject, { selectedLayerId: pasted.id }));
|
||||||
}),
|
},
|
||||||
|
|
||||||
resetProject: () => {
|
resetProject: () => {
|
||||||
releaseProjectObjectUrls(get().project);
|
releaseProjectObjectUrls(get().project);
|
||||||
|
|||||||
Reference in New Issue
Block a user