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:
+28
-14
@@ -1,5 +1,5 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
import { renderImageWithFaceBlur } from "./face-blur-renderer";
|
||||
|
||||
type ExportOptions = {
|
||||
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 width = Math.max(80, layer.width ?? 120);
|
||||
const height = Math.max(34, layer.height ?? 40);
|
||||
@@ -45,15 +49,24 @@ function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark:
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
||||
ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
||||
ctx.font =
|
||||
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, width / 2, height / 2);
|
||||
}
|
||||
|
||||
async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
|
||||
const width = layer.width ?? (layer.type === "image" ? Math.round(200 * layer.scale) : 120);
|
||||
const height = layer.height ?? (layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
||||
async function drawLayer(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layer: Layer,
|
||||
isDark: boolean,
|
||||
) {
|
||||
const width =
|
||||
layer.width ??
|
||||
(layer.type === "image" ? Math.round(200 * layer.scale) : 120);
|
||||
const height =
|
||||
layer.height ??
|
||||
(layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
||||
|
||||
ctx.save();
|
||||
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) {
|
||||
try {
|
||||
const image = await loadImage(layer.sourceUri);
|
||||
ctx.drawImage(image, 0, 0, width, height);
|
||||
if (layer.faceBlur && layer.faceBlur.regions.length > 0) {
|
||||
renderFaceBlurRegions(ctx, image, layer.faceBlur, width, height);
|
||||
}
|
||||
renderImageWithFaceBlur(ctx, image, layer.faceBlur, width, height);
|
||||
} catch {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
@@ -78,8 +88,14 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export async function exportProjectAsPng(project: Project, options: ExportOptions) {
|
||||
const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
|
||||
export async function exportProjectAsPng(
|
||||
project: Project,
|
||||
options: ExportOptions,
|
||||
) {
|
||||
const pixelRatio = Math.max(
|
||||
1,
|
||||
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
|
||||
);
|
||||
const { width, height } = project.canvas;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width * pixelRatio;
|
||||
@@ -89,8 +105,6 @@ export async function exportProjectAsPng(project: Project, options: ExportOption
|
||||
if (!ctx) throw new Error("Cannot create export canvas context");
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
ctx.fillStyle = options.isDark ? "#17181b" : "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
for (const layer of project.layers) {
|
||||
await drawLayer(ctx, layer, options.isDark);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
import {
|
||||
renderFaceBlurRegions,
|
||||
renderImageWithFaceBlur,
|
||||
} from "./face-blur-renderer";
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
@@ -27,7 +30,16 @@ describe("renderFaceBlurRegions", () => {
|
||||
{
|
||||
method: "gaussian",
|
||||
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,
|
||||
400,
|
||||
@@ -35,7 +47,17 @@ describe("renderFaceBlurRegions", () => {
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledOnce();
|
||||
expect(ctx.filter).toBe("blur(24px)");
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||
image,
|
||||
120,
|
||||
80,
|
||||
300,
|
||||
200,
|
||||
60,
|
||||
40,
|
||||
150,
|
||||
100,
|
||||
);
|
||||
expect(ctx.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -46,13 +68,22 @@ describe("renderFaceBlurRegions", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const sampleDrawImage = vi.fn();
|
||||
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
|
||||
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
|
||||
const sampleCtx = {
|
||||
imageSmoothingEnabled: true,
|
||||
drawImage: sampleDrawImage,
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const sampleCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => sampleCtx),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sampleCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
const createElement = vi
|
||||
.spyOn(doc, "createElement")
|
||||
.mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sampleCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
@@ -60,7 +91,16 @@ describe("renderFaceBlurRegions", () => {
|
||||
{
|
||||
method: "pixelate",
|
||||
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,
|
||||
400,
|
||||
@@ -68,8 +108,28 @@ describe("renderFaceBlurRegions", () => {
|
||||
|
||||
expect(sampleCanvas.width).toBe(16);
|
||||
expect(sampleCanvas.height).toBe(12);
|
||||
expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60);
|
||||
expect(sampleDrawImage).toHaveBeenCalledWith(
|
||||
image,
|
||||
200,
|
||||
100,
|
||||
160,
|
||||
120,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
12,
|
||||
);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(
|
||||
sampleCanvas,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
12,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
60,
|
||||
);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
@@ -83,7 +143,17 @@ describe("renderFaceBlurRegions", () => {
|
||||
method: "censor",
|
||||
amount: 20,
|
||||
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,
|
||||
400,
|
||||
@@ -108,6 +178,178 @@ describe("renderFaceBlurRegions", () => {
|
||||
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");
|
||||
if (!sampleCtx) return;
|
||||
sampleCtx.imageSmoothingEnabled = false;
|
||||
sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height);
|
||||
sampleCtx.drawImage(
|
||||
source,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
0,
|
||||
0,
|
||||
sampleCanvas.width,
|
||||
sampleCanvas.height,
|
||||
);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
||||
ctx.drawImage(
|
||||
sampleCanvas,
|
||||
0,
|
||||
0,
|
||||
sampleCanvas.width,
|
||||
sampleCanvas.height,
|
||||
targetX,
|
||||
targetY,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
function canUseCanvasFilter(ctx: CanvasRenderingContext2D) {
|
||||
return "filter" in ctx && typeof ctx.filter === "string";
|
||||
}
|
||||
|
||||
function drawBlurredRegionFallback(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
source: CanvasImageSource,
|
||||
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(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
blur: { method: FaceBlurSettings["method"]; amount: number; regions: BlurRegion[]; censorColor?: string },
|
||||
blur: {
|
||||
method: FaceBlurSettings["method"];
|
||||
amount: number;
|
||||
regions: BlurRegion[];
|
||||
censorColor?: string;
|
||||
},
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
@@ -49,10 +159,18 @@ export function renderFaceBlurRegions(
|
||||
const y = Math.max(0, Math.floor(region.y * scaleY));
|
||||
const w = Math.max(1, Math.floor(region.width * scaleX));
|
||||
const h = Math.max(1, Math.floor(region.height * scaleY));
|
||||
const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX));
|
||||
const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY));
|
||||
const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX));
|
||||
const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY));
|
||||
const sx0 = hasSourceDims
|
||||
? region.x
|
||||
: Math.max(0, Math.floor(region.x * legacyScaleX));
|
||||
const sy0 = hasSourceDims
|
||||
? region.y
|
||||
: Math.max(0, Math.floor(region.y * legacyScaleY));
|
||||
const sw = hasSourceDims
|
||||
? region.width
|
||||
: Math.max(1, Math.floor(region.width * legacyScaleX));
|
||||
const sh = hasSourceDims
|
||||
? region.height
|
||||
: Math.max(1, Math.floor(region.height * legacyScaleY));
|
||||
|
||||
if (blur.method === "censor") {
|
||||
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111";
|
||||
@@ -66,9 +184,84 @@ export function renderFaceBlurRegions(
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blur.amount}px)`;
|
||||
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
||||
ctx.restore();
|
||||
if (canUseCanvasFilter(ctx)) {
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blur.amount}px)`;
|
||||
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
||||
ctx.restore();
|
||||
continue;
|
||||
}
|
||||
|
||||
drawBlurredRegionFallback(
|
||||
ctx,
|
||||
image,
|
||||
sx0,
|
||||
sy0,
|
||||
sw,
|
||||
sh,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
blur.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
image.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -13,8 +18,15 @@ export async function loadImageFromUri(uri: string): Promise<HTMLImageElement |
|
||||
}
|
||||
|
||||
export function toFaceDetectionOverlays(
|
||||
faces: Array<{ x: number; y: number; width: number; height: number; gender?: string; genderScore?: number }>,
|
||||
image: HTMLImageElement,
|
||||
faces: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
gender?: string;
|
||||
genderScore?: number;
|
||||
}>,
|
||||
image: { naturalWidth: number; naturalHeight: number },
|
||||
layerWidth?: number,
|
||||
layerHeight?: number,
|
||||
): FaceDetectionOverlay[] {
|
||||
@@ -25,8 +37,11 @@ export function toFaceDetectionOverlays(
|
||||
|
||||
return faces.map((face, index) => {
|
||||
const genderLabel = face.gender ?? "unknown";
|
||||
const scoreLabel = face.genderScore != null ? `${Math.round(face.genderScore * 100)}%` : "";
|
||||
const label = scoreLabel ? `Person ${index + 1} - ${genderLabel} ${scoreLabel}` : `Person ${index + 1} - ${genderLabel}`;
|
||||
const scoreLabel =
|
||||
face.genderScore != null ? `${Math.round(face.genderScore * 100)}%` : "";
|
||||
const label = scoreLabel
|
||||
? `Person ${index + 1} - ${genderLabel} ${scoreLabel}`
|
||||
: `Person ${index + 1} - ${genderLabel}`;
|
||||
|
||||
return {
|
||||
x: face.x * scaleX,
|
||||
@@ -71,7 +86,10 @@ export function buildFacePreviews(
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return { id: `face-${index + 1}`, src: "" };
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[]> {
|
||||
const tf = await import("@tensorflow/tfjs");
|
||||
await tf.ready();
|
||||
export type FaceDetectionResult = {
|
||||
faces: FaceBox[];
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
|
||||
const faceapi = await import("@vladmandic/face-api");
|
||||
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"),
|
||||
]);
|
||||
type FaceDetectionWorkerRequest = {
|
||||
id: number;
|
||||
sourceUri: string;
|
||||
};
|
||||
|
||||
type TinyFaceOptions = { inputSize: number; scoreThreshold: number };
|
||||
const TinyFaceDetectorOptions = (
|
||||
faceapi as unknown as { TinyFaceDetectorOptions: new (options: TinyFaceOptions) => TinyFaceOptions }
|
||||
).TinyFaceDetectorOptions;
|
||||
type FaceDetectionWorkerResponse = {
|
||||
id: number;
|
||||
result?: FaceDetectionResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
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 },
|
||||
];
|
||||
let faceDetectionWorker: Worker | null = null;
|
||||
let faceDetectionWorkerDisabled = false;
|
||||
let workerRequestId = 0;
|
||||
const workerRequests = new Map<
|
||||
number,
|
||||
{
|
||||
resolve: (result: FaceDetectionResult) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
type FaceApiDet = {
|
||||
gender: string;
|
||||
genderProbability: number;
|
||||
detection: { box: { x: number; y: number; width: number; height: number } };
|
||||
};
|
||||
|
||||
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;
|
||||
export async function detectFaceBoxes(
|
||||
sourceUri: string,
|
||||
): Promise<FaceDetectionResult> {
|
||||
if (typeof Worker === "undefined" || faceDetectionWorkerDisabled) {
|
||||
throw new Error("Face detection worker is unavailable");
|
||||
}
|
||||
|
||||
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 };
|
||||
try {
|
||||
return await detectFaceBoxesInWorker(sourceUri);
|
||||
} catch (err) {
|
||||
faceDetectionWorkerDisabled = true;
|
||||
faceDetectionWorker?.terminate();
|
||||
faceDetectionWorker = null;
|
||||
console.warn("[face-detection] worker failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
function detectFaceBoxesInWorker(
|
||||
sourceUri: string,
|
||||
): Promise<FaceDetectionResult> {
|
||||
const worker = getFaceDetectionWorker();
|
||||
const id = ++workerRequestId;
|
||||
|
||||
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,
|
||||
};
|
||||
return new Promise<FaceDetectionResult>((resolve, reject) => {
|
||||
workerRequests.set(id, { resolve, reject });
|
||||
worker.postMessage({ id, sourceUri } satisfies FaceDetectionWorkerRequest);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user