feat: layer effects, bucket fill, simple brush

This commit is contained in:
2026-05-21 00:02:12 +07:00
parent bcf4650c70
commit e7dd9420e7
47 changed files with 1675 additions and 1038 deletions
+31 -22
View File
@@ -1,34 +1,37 @@
import React from "react";
import type { FaceBlurMethod, Layer } from "@pien-studio/types";
import type { FaceBlurMethod, Layer, LayerEffect } from "@pien-studio/types";
import type { FaceDetectionOverlay } from "./use-face-detection";
type FaceBlurPreview = {
layerId: string;
method: FaceBlurMethod;
amount: number;
regions: { x: number; y: number; width: number; height: number; sourceWidth: number; sourceHeight: number; censorColor?: string }[];
censorColor?: string;
effects: LayerEffect[];
};
type UseFaceBlurWorkflowOptions = {
selectedLayer: Layer | null;
faceDetectionsLayerId: string | null;
faceDetections: FaceDetectionOverlay[];
setImageLayerFaceBlur: (layerId: string, faceBlur: Layer["faceBlur"] | undefined) => void;
setLayerEffect: (layerId: string, effect: LayerEffect) => void;
removeLayerEffect: (layerId: string, kind: LayerEffect["kind"]) => void;
};
export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options;
const { selectedLayer, faceDetectionsLayerId, faceDetections, setLayerEffect, removeLayerEffect } = options;
const [blurMethod, setBlurMethod] = React.useState<FaceBlurMethod>("gaussian");
const [blurAmount, setBlurAmount] = React.useState(14);
const [censorColor, setCensorColor] = React.useState("#111111");
const [selectedFaceIndices, setSelectedFaceIndices] = React.useState<number[]>([]);
const [faceBlurPreview, setFaceBlurPreview] = React.useState<FaceBlurPreview | null>(null);
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "image" && faceDetectionsLayerId === selectedLayer.id);
const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "raster" && faceDetectionsLayerId === selectedLayer.id);
const faceBlurEffect = React.useMemo(
() => selectedLayer?.effects.find((e): e is Extract<LayerEffect, { kind: "face-blur" }> => e.kind === "face-blur") ?? null,
[selectedLayer?.effects],
);
const buildBlurRegions = React.useCallback(
(indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "image") return [];
if (!selectedLayer || selectedLayer.type !== "raster") return [];
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return [];
const indexSet = new Set(indices);
return faceDetections
@@ -57,17 +60,19 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
}, []);
const clearBlur = React.useCallback(() => {
if (!selectedLayer || selectedLayer.type !== "image") return;
setImageLayerFaceBlur(selectedLayer.id, undefined);
if (!selectedLayer) return;
removeLayerEffect(selectedLayer.id, "face-blur");
setFaceBlurPreview(null);
}, [selectedLayer, setImageLayerFaceBlur]);
}, [selectedLayer, removeLayerEffect]);
const blurFaces = React.useCallback(
(indices: number[]) => {
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) return;
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) return;
if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return;
const regions = buildBlurRegions(indices);
setImageLayerFaceBlur(selectedLayer.id, {
setLayerEffect(selectedLayer.id, {
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions,
@@ -76,7 +81,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setSelectedFaceIndices([]);
setFaceBlurPreview(null);
},
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur],
[blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setLayerEffect],
);
React.useEffect(() => {
@@ -85,7 +90,7 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setFaceBlurPreview(null);
return;
}
if (selectedLayer?.faceBlur) {
if (faceBlurEffect) {
setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : []));
return;
}
@@ -94,10 +99,10 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev;
return next;
});
}, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]);
}, [faceDetections, hasDetectableSelection, faceBlurEffect]);
React.useEffect(() => {
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") {
if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "raster") {
setFaceBlurPreview(null);
return;
}
@@ -109,10 +114,14 @@ export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) {
setFaceBlurPreview({
layerId: selectedLayer.id,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
effects: [{
kind: "face-blur",
enabled: true,
method: blurMethod,
amount: blurAmount,
regions: buildBlurRegions(selectedFaceIndices),
censorColor,
}],
});
}, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]);