♻️ refactor!: massive refactor

This commit is contained in:
2026-05-31 03:04:30 +07:00
parent c1f12c7201
commit df3c944547
86 changed files with 3691 additions and 1129 deletions
+6 -3
View File
@@ -1,11 +1,14 @@
"use client";
import React from "react";
import { startAssetCleanupJob } from "@pien-studio/storage";
import { localProjectRepository } from "../lib/project-repository";
export function useAssetCleanupJob() {
React.useEffect(() => {
const stop = startAssetCleanupJob();
return () => stop();
if (typeof window === "undefined") return undefined;
const id = window.setInterval(() => {
void localProjectRepository.cleanupAssets();
}, 45_000);
return () => window.clearInterval(id);
}, []);
}
+130 -36
View File
@@ -23,7 +23,13 @@ type UseCanvasInteractionsOptions = {
onInteractionEnd?: () => void;
onContextMenu?: (x: number, y: number) => void;
onFillLayer?: (layerId: string, x: number, y: number) => void;
onBrushStrokeStart?: (layerId: string, x: number, y: number, layerWidth: number, layerHeight: number) => void;
onBrushStrokeStart?: (
layerId: string,
x: number,
y: number,
layerWidth: number,
layerHeight: number,
) => void;
onBrushStrokeMove?: (layerId: string, x: number, y: number) => void;
onBrushStrokeEnd?: (layerId: string) => void;
};
@@ -53,7 +59,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onBrushStrokeMove,
onBrushStrokeEnd,
} = 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 isPanning = React.useRef(false);
const lastPos = React.useRef({ x: 0, y: 0 });
@@ -64,7 +74,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
const interactionActiveRef = React.useRef(false);
const dragMoveRafRef = React.useRef<number | null>(null);
const resizeMoveRafRef = React.useRef<number | null>(null);
const resizeMovePendingRef = React.useRef<{ width: number; height: number; x: number; y: number } | null>(null);
const resizeMovePendingRef = React.useRef<{
width: number;
height: number;
x: number;
y: number;
} | null>(null);
const dragRef = React.useRef<{
id: string;
startLayerX: number;
@@ -95,7 +110,12 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
startRotation: number;
lastRotation: number;
} | null>(null);
const brushRef = React.useRef<{ id: string; lastX: number; lastY: number; rect: DOMRect } | null>(null);
const brushRef = React.useRef<{
id: string;
lastX: number;
lastY: number;
rect: DOMRect;
} | null>(null);
const pinchRef = React.useRef<{
active: boolean;
@@ -119,7 +139,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onInteractionEnd?.();
}
function clientDist(t0: Pick<Touch, "clientX" | "clientY">, t1: Pick<Touch, "clientX" | "clientY">) {
function clientDist(
t0: Pick<Touch, "clientX" | "clientY">,
t1: Pick<Touch, "clientX" | "clientY">,
) {
const dx = t0.clientX - t1.clientX;
const dy = t0.clientY - t1.clientY;
return Math.sqrt(dx * dx + dy * dy);
@@ -130,12 +153,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
e.stopPropagation();
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const factor = e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR;
const factor =
e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR;
if (!Number.isFinite(factor) || factor === 0) return;
const pivotX = e.clientX - rect.left;
const pivotY = e.clientY - rect.top;
setViewport((vp) => {
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
const nextScale = Math.max(
MIN_SCALE,
Math.min(MAX_SCALE, vp.scale * factor),
);
const scaleChange = nextScale / vp.scale;
return {
x: pivotX - (pivotX - vp.x) * scaleChange,
@@ -147,7 +174,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
function zoomBy(factor: number, pivotX: number, pivotY: number) {
setViewport((vp) => {
const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor));
const newScale = Math.max(
MIN_SCALE,
Math.min(MAX_SCALE, vp.scale * factor),
);
const scaleChange = newScale / vp.scale;
return {
x: pivotX - (pivotX - vp.x) * scaleChange,
@@ -193,13 +223,21 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
React.useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.code === "Space") {
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
if (
!(e.target instanceof HTMLElement) ||
/^(input|textarea|select)$/i.test(e.target.tagName)
)
return;
if (!isSpacePan) setIsSpacePan(true);
e.preventDefault();
return;
}
if (e.key === "Shift") {
if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return;
if (
!(e.target instanceof HTMLElement) ||
/^(input|textarea|select)$/i.test(e.target.tagName)
)
return;
setIsShiftPressed(true);
return;
}
@@ -231,11 +269,16 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("wheel", handleWheelCaptured, { capture: true, passive: false });
window.addEventListener("wheel", handleWheelCaptured, {
capture: true,
passive: false,
});
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("wheel", handleWheelCaptured, { capture: true });
window.removeEventListener("wheel", handleWheelCaptured, {
capture: true,
});
};
}, [isSpacePan]);
@@ -281,7 +324,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
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 delta = currentAngle - startAngle;
const nextRotation = startRotation + (delta * 180) / Math.PI;
@@ -312,7 +356,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
nextHeight = Math.max(8, resizeRef.current.startHeight - dy);
}
if (!e.shiftKey && (corner === "br" || corner === "bl" || corner === "tr" || corner === "tl")) {
if (
!e.shiftKey &&
(corner === "br" ||
corner === "bl" ||
corner === "tr" ||
corner === "tl")
) {
const aspect = resizeRef.current.aspect || 1;
if (Math.abs(dx) > Math.abs(dy)) {
nextHeight = Math.max(8, nextWidth / aspect);
@@ -321,8 +371,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
}
}
if (corner === "bl" || corner === "tl") offsetX = resizeRef.current.startWidth - nextWidth;
if (corner === "tr" || corner === "tl") offsetY = resizeRef.current.startHeight - nextHeight;
if (corner === "bl" || corner === "tl")
offsetX = resizeRef.current.startWidth - nextWidth;
if (corner === "tr" || corner === "tl")
offsetY = resizeRef.current.startHeight - nextHeight;
resizeRef.current.lastWidth = nextWidth;
resizeRef.current.lastHeight = nextHeight;
@@ -338,7 +390,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
if (!resizeRef.current || !resizeMovePendingRef.current) return;
const pending = resizeMovePendingRef.current;
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
if (
(pending.x !== resizeRef.current.startLayerX ||
pending.y !== resizeRef.current.startLayerY) &&
onMoveLayer
) {
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
}
});
@@ -349,14 +405,19 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale;
const nextX = dragRef.current.startLayerX + dx;
const nextY = dragRef.current.startLayerY + dy;
if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY) return;
if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY)
return;
dragRef.current.lastX = nextX;
dragRef.current.lastY = nextY;
if (dragMoveRafRef.current !== null) return;
dragMoveRafRef.current = window.requestAnimationFrame(() => {
dragMoveRafRef.current = null;
if (!dragRef.current) return;
onMoveLayer(dragRef.current.id, dragRef.current.lastX, dragRef.current.lastY);
onMoveLayer(
dragRef.current.id,
dragRef.current.lastX,
dragRef.current.lastY,
);
});
}
}
@@ -372,7 +433,11 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
if (resizeRef.current && resizeMovePendingRef.current && onResizeLayer) {
const pending = resizeMovePendingRef.current;
onResizeLayer(resizeRef.current.id, pending.width, pending.height);
if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) {
if (
(pending.x !== resizeRef.current.startLayerX ||
pending.y !== resizeRef.current.startLayerY) &&
onMoveLayer
) {
onMoveLayer(resizeRef.current.id, pending.x, pending.y);
}
}
@@ -381,11 +446,13 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
isMiddleMousePan.current = false;
if (dragRef.current && onMoveLayerEnd) {
const { id, lastX, lastY } = dragRef.current;
if (typeof lastX === "number" && typeof lastY === "number") onMoveLayerEnd(id, lastX, lastY);
if (typeof lastX === "number" && typeof lastY === "number")
onMoveLayerEnd(id, lastX, lastY);
}
if (resizeRef.current && onResizeLayerEnd) {
const { id, lastWidth, lastHeight } = resizeRef.current;
if (typeof lastWidth === "number" && typeof lastHeight === "number") onResizeLayerEnd(id, lastWidth, lastHeight);
if (typeof lastWidth === "number" && typeof lastHeight === "number")
onResizeLayerEnd(id, lastWidth, lastHeight);
}
if (rotateRef.current && onRotateLayerEnd) {
const { id, lastRotation } = rotateRef.current;
@@ -402,7 +469,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
endInteraction();
}
function onLayerPointerDown(e: React.PointerEvent<HTMLDivElement>, layer: Layer) {
function onLayerPointerDown(
e: React.PointerEvent<HTMLDivElement>,
layer: Layer,
) {
if (isSpacePan) return;
e.stopPropagation();
const toolDef = getToolDefinition(tool);
@@ -413,9 +483,14 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
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);
brushRef.current = {
id: layer.id,
lastX: localX,
lastY: localY,
rect: e.currentTarget.getBoundingClientRect(),
};
const lw = layer.width;
const lh = layer.height;
onBrushStrokeStart(layer.id, localX, localY, lw, lh);
beginInteraction();
} else if (onFillLayer) {
@@ -437,13 +512,17 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onSelectLayer(layer.id);
}
function onResizeHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer, corner: string) {
function onResizeHandleDown(
e: React.PointerEvent<HTMLButtonElement>,
layer: Layer,
corner: string,
) {
if (!getToolDefinition(tool)?.allowsLayerResize || !onResizeLayer) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
rotateRef.current = null;
const width = layer.width ?? Math.round(200 * layer.scale);
const height = layer.height ?? Math.round(150 * layer.scale);
const width = layer.width;
const height = layer.height;
resizeRef.current = {
id: layer.id,
startWidth: width,
@@ -461,16 +540,27 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
onSelectLayer(layer.id);
}
function onRotateHandleDown(e: React.PointerEvent<HTMLButtonElement>, layer: Layer) {
function onRotateHandleDown(
e: React.PointerEvent<HTMLButtonElement>,
layer: Layer,
) {
if (!getToolDefinition(tool)?.allowsLayerRotate || !onRotateLayer) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
resizeRef.current = null;
const rect = containerRef.current?.getBoundingClientRect();
const width = layer.width ?? Math.round(200 * layer.scale);
const height = layer.height ?? Math.round(150 * layer.scale);
const centerX = (rect?.left ?? 0) + viewport.x + layer.x * viewport.scale + (width * viewport.scale) / 2;
const centerY = (rect?.top ?? 0) + viewport.y + layer.y * viewport.scale + (height * viewport.scale) / 2;
const width = layer.width;
const height = layer.height;
const centerX =
(rect?.left ?? 0) +
viewport.x +
layer.x * viewport.scale +
(width * viewport.scale) / 2;
const centerY =
(rect?.top ?? 0) +
viewport.y +
layer.y * viewport.scale +
(height * viewport.scale) / 2;
const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
rotateRef.current = {
id: layer.id,
@@ -488,7 +578,8 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
if (e.touches.length === 2) {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const midX =
(e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
pinchRef.current = {
active: true,
@@ -507,7 +598,10 @@ export function useCanvasInteractions(options: UseCanvasInteractionsOptions) {
e.preventDefault();
const px = clientDist(e.touches[0], e.touches[1]);
const ratio = px / pinchRef.current.initialPinchPx;
const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio));
const nextScale = Math.max(
MIN_SCALE,
Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio),
);
const scaleChange = nextScale / pinchRef.current.initialScale;
const { pivotX, pivotY, initialX, initialY } = pinchRef.current;
setViewport({
+3 -1
View File
@@ -11,7 +11,9 @@ describe("useEditorBindings", () => {
const { result } = renderHook(() => useEditorBindings());
expect(result.current.state.project.layers.length).toBe(1);
expect(result.current.state.selectedLayer?.id).toBe(result.current.state.selectedLayerId);
expect(result.current.state.selectedLayer?.id).toBe(
result.current.state.selectedLayerId,
);
expect(typeof result.current.actions.undo).toBe("function");
});
});
+4 -1
View File
@@ -53,7 +53,10 @@ export function useEditorBindings() {
);
const selectedLayer = React.useMemo(
() => state.project.layers.find((layer) => layer.id === state.selectedLayerId) ?? null,
() =>
state.project.layers.find(
(layer) => layer.id === state.selectedLayerId,
) ?? null,
[state.project.layers, state.selectedLayerId],
);
+11 -3
View File
@@ -4,14 +4,22 @@ import { CONTEXT_MENU_SIZE } from "../lib/editor-constants";
type ContextMenuPosition = { x: number; y: number };
export function useEditorContextMenu() {
const [contextMenu, setContextMenu] = React.useState<ContextMenuPosition | null>(null);
const [contextMenu, setContextMenu] =
React.useState<ContextMenuPosition | null>(null);
const openContextMenu = React.useCallback((x: number, y: number) => {
const rect = document.documentElement.getBoundingClientRect();
const { width: menuWidth, height: menuHeight, viewportPadding: pad } = CONTEXT_MENU_SIZE;
const {
width: menuWidth,
height: menuHeight,
viewportPadding: pad,
} = CONTEXT_MENU_SIZE;
const maxX = rect.width - menuWidth - pad;
const maxY = rect.height - menuHeight - pad;
setContextMenu({ x: Math.max(pad, Math.min(x, maxX)), y: Math.max(pad, Math.min(y, maxY)) });
setContextMenu({
x: Math.max(pad, Math.min(x, maxX)),
y: Math.max(pad, Math.min(y, maxY)),
});
}, []);
const closeContextMenu = React.useCallback(() => {
+11 -3
View File
@@ -1,6 +1,9 @@
import React from "react";
type Translator = (key: string, params?: Record<string, string | number>) => string;
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
export function useEditorLabels(t: Translator) {
const headerLabels = React.useMemo(
@@ -29,7 +32,11 @@ export function useEditorLabels(t: Translator) {
);
const contextMenuLabels = React.useMemo(
() => ({ copy: t("editor.copy"), cut: t("editor.cut"), paste: t("editor.paste") }),
() => ({
copy: t("editor.copy"),
cut: t("editor.cut"),
paste: t("editor.paste"),
}),
[t],
);
@@ -38,7 +45,8 @@ export function useEditorLabels(t: Translator) {
resize: t("editor.resize"),
faceMlFailedShort: t("editor.faceMlFailedShort"),
detectingFacesShort: t("editor.detectingFacesShort"),
faceDetectionTip: (count: number) => t("editor.faceDetectionTip", { count }),
faceDetectionTip: (count: number) =>
t("editor.faceDetectionTip", { count }),
import: t("editor.import"),
mood: t("editor.mood"),
quick: t("editor.quick"),
@@ -1,8 +1,11 @@
import React from "react";
import { createProject } from "@pien-studio/editor-core";
import { upsertProject } from "@pien-studio/storage";
import { localProjectRepository } from "../lib/project-repository";
type Translator = (key: string, params?: Record<string, string | number>) => string;
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
type UseEditorProjectLifecycleOptions = {
projectId: string;
@@ -12,7 +15,9 @@ type UseEditorProjectLifecycleOptions = {
t: Translator;
};
export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOptions) {
export function useEditorProjectLifecycle(
options: UseEditorProjectLifecycleOptions,
) {
const { projectId, hydrate, loadProjectById, setProject, t } = options;
const initializedProjectId = React.useRef<string | null>(null);
@@ -27,7 +32,7 @@ export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOpti
if (projectId === "new") {
const nextProject = createProject(t("home.untitledProject"));
setProject(nextProject);
void upsertProject(nextProject);
void localProjectRepository.upsertProject(nextProject);
return;
}
void loadProjectById(projectId);
+4 -1
View File
@@ -11,7 +11,10 @@ type UseEditorShortcutsOptions = {
};
function isEditableTarget(e: Event) {
return e.target instanceof HTMLElement && /^(input|textarea|select)$/i.test(e.target.tagName);
return (
e.target instanceof HTMLElement &&
/^(input|textarea|select)$/i.test(e.target.tagName)
);
}
export function useEditorShortcuts(options: UseEditorShortcutsOptions) {
+68 -10
View File
@@ -7,16 +7,18 @@ function makeImageLayer(overrides: Partial<Layer> = {}): Layer {
return {
id: "layer-1",
type: "raster",
sourceUri: "data:image/png;base64,abc",
asset: { kind: "inline", uri: "data:image/png;base64,abc" },
x: 0,
y: 0,
width: 100,
height: 100,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
...overrides,
};
} as Layer;
}
describe("useFaceBlurWorkflow", () => {
@@ -25,8 +27,24 @@ describe("useFaceBlurWorkflow", () => {
const removeLayerEffect = vi.fn();
const selectedLayer = makeImageLayer();
const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
{ x: 5, y: 8, width: 7, height: 9, label: "b", sourceWidth: 100, sourceHeight: 100 },
{
x: 1,
y: 2,
width: 10,
height: 12,
label: "a",
sourceWidth: 100,
sourceHeight: 100,
},
{
x: 5,
y: 8,
width: 7,
height: 9,
label: "b",
sourceWidth: 100,
sourceHeight: 100,
},
];
const { result } = renderHook(() =>
@@ -41,8 +59,14 @@ describe("useFaceBlurWorkflow", () => {
await waitFor(() => {
expect(result.current.selectedFaceIndices).toEqual([0, 1]);
const faceBlurEffect = result.current.faceBlurPreview?.effects.find((e) => e.kind === "face-blur");
expect(faceBlurEffect?.kind === "face-blur" ? faceBlurEffect.regions : undefined).toHaveLength(2);
const faceBlurEffect = result.current.faceBlurPreview?.effects.find(
(e) => e.kind === "face-blur",
);
expect(
faceBlurEffect?.kind === "face-blur"
? faceBlurEffect.regions
: undefined,
).toHaveLength(2);
});
});
@@ -62,7 +86,17 @@ describe("useFaceBlurWorkflow", () => {
useFaceBlurWorkflow({
selectedLayer,
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,
},
],
setLayerEffect,
removeLayerEffect,
}),
@@ -79,8 +113,24 @@ describe("useFaceBlurWorkflow", () => {
const removeLayerEffect = vi.fn();
const selectedLayer = makeImageLayer();
const faceDetections = [
{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 },
{ x: 50, y: 60, width: 20, height: 22, label: "b", sourceWidth: 100, sourceHeight: 100 },
{
x: 1,
y: 2,
width: 10,
height: 12,
label: "a",
sourceWidth: 100,
sourceHeight: 100,
},
{
x: 50,
y: 60,
width: 20,
height: 22,
label: "b",
sourceWidth: 100,
sourceHeight: 100,
},
];
const { result } = renderHook(() =>
@@ -115,7 +165,15 @@ describe("useFaceBlurWorkflow", () => {
method: "gaussian",
amount: 14,
regions: expect.arrayContaining([
expect.objectContaining({ x: 1, y: 2, width: 10, height: 12, censorColor: "#111111", sourceWidth: 100, sourceHeight: 100 }),
expect.objectContaining({
x: 1,
y: 2,
width: 10,
height: 12,
censorColor: "#111111",
sourceWidth: 100,
sourceHeight: 100,
}),
]),
}),
);
+99 -30
View File
@@ -1,5 +1,10 @@
import React from "react";
import type { FaceBlurMethod, Layer, LayerEffect } from "@pien-studio/types";
import {
getLayerRuntimeSource,
type FaceBlurMethod,
type Layer,
type LayerEffect,
} from "@pien-studio/types";
import type { FaceDetectionOverlay } from "./use-face-detection";
type FaceBlurPreview = {
@@ -21,15 +26,31 @@ type UseFaceBlurWorkflowOptions = {
};
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options;
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
const {
selectedLayer,
faceDetectionsLayerId,
faceDetections,
setLayerEffect,
removeLayerEffect,
} = options;
const [blurMethod, setBlurMethod] =
React.useState<FaceBlurMethod>("gaussian");
const [blurAmount, setBlurAmount] = React.useState(14);
const [censorColor, setCensorColor] = React.useState("#111111");
const [faceSelection, setFaceSelection] = React.useState<FaceSelectionState | null>(null);
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
const [faceSelection, setFaceSelection] =
React.useState<FaceSelectionState | null>(null);
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.find(
(e): e is Extract<LayerEffect, { kind: "face-blur" }> =>
e.kind === "face-blur",
) ?? null,
[selectedLayer?.effects],
);
@@ -40,18 +61,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
return faceDetections.map((_, index) => index);
}, [faceDetections, faceBlurEffect, hasDetectableSelection]);
const selectedFaceIndices = faceSelection?.key === selectionKey ? faceSelection.indices : defaultSelectedFaceIndices;
const selectedFaceIndices =
faceSelection?.key === selectionKey
? faceSelection.indices
: defaultSelectedFaceIndices;
const buildBlurRegions = React.useCallback(
(indices: number[]) => {
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);
return faceDetections
.filter((_, index) => indexSet.has(index))
.map((face) => {
const baseWidth = Math.max(1, selectedLayer.width ?? face.sourceWidth);
const baseHeight = Math.max(1, selectedLayer.height ?? face.sourceHeight);
const baseWidth = Math.max(1, selectedLayer.width);
const baseHeight = Math.max(1, selectedLayer.height);
const scaleX = face.sourceWidth / baseWidth;
const scaleY = face.sourceHeight / baseHeight;
return {
@@ -68,13 +96,21 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
[censorColor, faceDetections, faceDetectionsLayerId, selectedLayer],
);
const toggleFaceIndex = React.useCallback((index: number) => {
setFaceSelection((prev) => {
const current = prev?.key === selectionKey ? prev.indices : defaultSelectedFaceIndices;
const indices = current.includes(index) ? current.filter((item) => item !== index) : [...current, index];
return { key: selectionKey, indices };
});
}, [defaultSelectedFaceIndices, selectionKey]);
const toggleFaceIndex = React.useCallback(
(index: number) => {
setFaceSelection((prev) => {
const current =
prev?.key === selectionKey
? prev.indices
: defaultSelectedFaceIndices;
const indices = current.includes(index)
? current.filter((item) => item !== index)
: [...current, index];
return { key: selectionKey, indices };
});
},
[defaultSelectedFaceIndices, selectionKey],
);
const clearBlur = React.useCallback(() => {
if (!selectedLayer) return;
@@ -84,8 +120,17 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
const blurFaces = React.useCallback(
(indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return;
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
if (
!selectedLayer ||
selectedLayer.type !== "raster" ||
!getLayerRuntimeSource(selectedLayer)
)
return;
if (
faceDetectionsLayerId !== selectedLayer.id ||
faceDetections.length === 0
)
return;
const regions = buildBlurRegions(indices);
setLayerEffect(selectedLayer.id, {
kind: "face-blur",
@@ -97,11 +142,25 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
});
setFaceSelection({ key: selectionKey, indices: [] });
},
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, selectionKey, setLayerEffect],
[
blurAmount,
blurMethod,
buildBlurRegions,
censorColor,
faceDetections.length,
faceDetectionsLayerId,
selectedLayer,
selectionKey,
setLayerEffect,
],
);
const faceBlurPreview = React.useMemo<FaceBlurPreview | null>(() => {
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
if (
!hasDetectableSelection ||
!selectedLayer ||
selectedLayer.type !== "raster"
) {
return null;
}
@@ -111,16 +170,26 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
return {
layerId: selectedLayer.id,
effects: [{
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
}],
effects: [
{
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
},
],
};
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);
}, [
blurAmount,
blurMethod,
buildBlurRegions,
censorColor,
hasDetectableSelection,
selectedFaceIndices,
selectedLayer,
]);
return {
blurMethod,
+3 -7
View File
@@ -26,8 +26,8 @@ export type FacePreview = {
type SelectedImageLayer = {
id: string;
sourceUri: string;
width?: number;
height?: number;
width: number;
height: number;
};
type UseFaceDetectionOptions = {
@@ -37,11 +37,7 @@ type UseFaceDetectionOptions = {
};
export function useFaceDetection(options: UseFaceDetectionOptions) {
const {
tool,
selectedImageLayer,
activeLayerStillSelected,
} = options;
const { tool, selectedImageLayer, activeLayerStillSelected } = options;
const selectedImageLayerId = selectedImageLayer?.id ?? null;
const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null;
const selectedImageWidth = selectedImageLayer?.width;
+5 -2
View File
@@ -28,7 +28,10 @@ export function useTranslations() {
const locale = useUiStore((s) => s.locale);
const msg = messages[locale] ?? messages.en;
function t(key: TranslationKey, params?: Record<string, string | number>): string {
function t(
key: TranslationKey,
params?: Record<string, string | number>,
): string {
let value = getNestedValue(msg as unknown as Record<string, unknown>, key);
if (params) {
Object.entries(params).forEach(([k, v]) => {
@@ -39,4 +42,4 @@ export function useTranslations() {
}
return { t, locale };
}
}