mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: initial app
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFaceLabelOverlays } from "./canvas-geometry";
|
||||
|
||||
describe("buildFaceLabelOverlays", () => {
|
||||
it("returns no overlays when target layer is missing", () => {
|
||||
const overlays = buildFaceLabelOverlays([], "missing", [{ x: 10, y: 10, width: 20, height: 20 }], { x: 0, y: 0, scale: 1 });
|
||||
expect(overlays).toEqual([]);
|
||||
});
|
||||
|
||||
it("stacks overlapping labels to avoid collisions", () => {
|
||||
const layers = [
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "image" as const,
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 180,
|
||||
height: 120,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const overlays = buildFaceLabelOverlays(
|
||||
layers,
|
||||
"layer-1",
|
||||
[
|
||||
{ x: 16, y: 20, width: 30, height: 30, label: "A" },
|
||||
{ x: 17, y: 21, width: 30, height: 30, label: "B" },
|
||||
],
|
||||
{ x: 0, y: 0, scale: 1 },
|
||||
);
|
||||
|
||||
expect(overlays).toHaveLength(2);
|
||||
expect(overlays[0]?.top).toBeGreaterThan(overlays[1]?.top ?? 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Layer } from "@pien-studio/types";
|
||||
|
||||
type FaceDetection = { x: number; y: number; width: number; height: number; label?: string };
|
||||
|
||||
type Viewport = { x: number; y: number; scale: number };
|
||||
|
||||
export function buildFaceLabelOverlays(
|
||||
layers: Layer[],
|
||||
faceOverlayLayerId: string,
|
||||
faceDetections: FaceDetection[],
|
||||
viewport: Viewport,
|
||||
) {
|
||||
const layer = layers.find((item) => item.id === faceOverlayLayerId);
|
||||
if (!layer) return [];
|
||||
|
||||
const isImage = layer.type === "image";
|
||||
const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined);
|
||||
const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined);
|
||||
if (!layerWidth || !layerHeight) return [];
|
||||
|
||||
const centerX = layer.x + layerWidth / 2;
|
||||
const centerY = layer.y + layerHeight / 2;
|
||||
const radians = (layer.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
const placed: Array<{ left: number; top: number; width: number; height: number }> = [];
|
||||
|
||||
return faceDetections.map((face, index) => {
|
||||
const worldX = layer.x + face.x;
|
||||
const worldY = layer.y + face.y;
|
||||
const dx = worldX - centerX;
|
||||
const dy = worldY - centerY;
|
||||
const rotatedWorldX = centerX + dx * cos - dy * sin;
|
||||
const rotatedWorldY = centerY + dx * sin + dy * cos;
|
||||
|
||||
const text = face.label ?? `Person ${index + 1}`;
|
||||
const estimatedWidth = Math.max(72, Math.min(260, text.length * 6 + 14));
|
||||
const estimatedHeight = 18;
|
||||
const left = viewport.x + rotatedWorldX * viewport.scale;
|
||||
let top = viewport.y + rotatedWorldY * viewport.scale - 22;
|
||||
|
||||
while (
|
||||
placed.some((rect) => {
|
||||
const intersectsX = left < rect.left + rect.width && left + estimatedWidth > rect.left;
|
||||
const intersectsY = top < rect.top + rect.height && top + estimatedHeight > rect.top;
|
||||
return intersectsX && intersectsY;
|
||||
})
|
||||
) {
|
||||
top -= estimatedHeight + 4;
|
||||
}
|
||||
|
||||
placed.push({ left, top, width: estimatedWidth, height: estimatedHeight });
|
||||
return { id: `${layer.id}-face-label-${index}`, text, left, top };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const AUTOSAVE_DELAY_MS = 1200;
|
||||
|
||||
export const CONTEXT_MENU_SIZE = {
|
||||
width: 170,
|
||||
height: 110,
|
||||
viewportPadding: 8,
|
||||
} as const;
|
||||
|
||||
export const CANVAS_HANDLE_BASE_SIZE = 12;
|
||||
export const CANVAS_ROTATE_HANDLE_BASE_SIZE = 18;
|
||||
|
||||
export const MIN_LAYER_SIZE = 8;
|
||||
|
||||
export const DEFAULT_IMAGE_IMPORT = {
|
||||
fallbackWidth: 200,
|
||||
fallbackHeight: 150,
|
||||
offsetX: 60,
|
||||
offsetY: 60,
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { EditorToolId } from "../store/editor-store";
|
||||
|
||||
export type ToolModeController = {
|
||||
kind: "mode";
|
||||
id: EditorToolId;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ToolActionController = {
|
||||
kind: "action";
|
||||
id: string;
|
||||
label: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
export type EditorToolController = ToolModeController | ToolActionController;
|
||||
|
||||
type CreateEditorToolControllersOptions = {
|
||||
onAddTextLayer: () => void;
|
||||
onImportImage: () => void;
|
||||
labels: {
|
||||
pointer: string;
|
||||
pan: string;
|
||||
face: string;
|
||||
text: string;
|
||||
image: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function createEditorToolControllers(options: CreateEditorToolControllersOptions): EditorToolController[] {
|
||||
return [
|
||||
{ kind: "mode", id: "pointer", label: options.labels.pointer },
|
||||
{ kind: "mode", id: "hand", label: options.labels.pan },
|
||||
{ kind: "mode", id: "face", label: options.labels.face },
|
||||
{ kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer },
|
||||
{ kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
|
||||
type ExportOptions = {
|
||||
isDark: boolean;
|
||||
pixelRatio?: number;
|
||||
};
|
||||
|
||||
function clampOpacity(value: number | undefined) {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return 1;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = reject;
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const radius = 8;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(radius, 0);
|
||||
ctx.lineTo(width - radius, 0);
|
||||
ctx.quadraticCurveTo(width, 0, width, radius);
|
||||
ctx.lineTo(width, height - radius);
|
||||
ctx.quadraticCurveTo(width, height, width - radius, height);
|
||||
ctx.lineTo(radius, height);
|
||||
ctx.quadraticCurveTo(0, height, 0, height - radius);
|
||||
ctx.lineTo(0, radius);
|
||||
ctx.quadraticCurveTo(0, 0, radius, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = isDark ? "#2d3036" : "#ffffff";
|
||||
ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
||||
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);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clampOpacity(layer.opacity);
|
||||
ctx.translate(layer.x + width / 2, layer.y + height / 2);
|
||||
ctx.rotate((layer.rotation * Math.PI) / 180);
|
||||
ctx.translate(-width / 2, -height / 2);
|
||||
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
} else {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
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;
|
||||
canvas.height = height * pixelRatio;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
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);
|
||||
}
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = `${project.title || "pien-project"}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderFaceBlurRegions } from "./face-blur-renderer";
|
||||
|
||||
function makeContext() {
|
||||
return {
|
||||
fillStyle: "",
|
||||
filter: "none",
|
||||
imageSmoothingEnabled: true,
|
||||
fillRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function makeImage(width = 1200, height = 800) {
|
||||
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
|
||||
}
|
||||
|
||||
describe("renderFaceBlurRegions", () => {
|
||||
it("renders gaussian blur region with source dimensions", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledOnce();
|
||||
expect(ctx.filter).toBe("blur(24px)");
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100);
|
||||
expect(ctx.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders pixelate using sampled offscreen canvas", () => {
|
||||
const doc = globalThis.document;
|
||||
expect(doc).toBeDefined();
|
||||
if (!doc) return;
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const sampleDrawImage = vi.fn();
|
||||
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
|
||||
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
|
||||
const nativeCreateElement = doc.createElement.bind(doc);
|
||||
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
|
||||
if (tagName === "canvas") return sampleCanvas;
|
||||
return nativeCreateElement(tagName);
|
||||
});
|
||||
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "pixelate",
|
||||
amount: 10,
|
||||
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(sampleCanvas.width).toBe(16);
|
||||
expect(sampleCanvas.height).toBe(12);
|
||||
expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60);
|
||||
createElement.mockRestore();
|
||||
});
|
||||
|
||||
it("renders censor with region color priority", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "censor",
|
||||
amount: 20,
|
||||
censorColor: "#ff0000",
|
||||
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.fillStyle).toBe("#00ff00");
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25);
|
||||
});
|
||||
|
||||
it("falls back to legacy region scaling when source dimensions are missing", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage(2400, 1600);
|
||||
renderFaceBlurRegions(
|
||||
ctx,
|
||||
image,
|
||||
{
|
||||
method: "gaussian",
|
||||
amount: 16,
|
||||
regions: [{ x: 100, y: 120, width: 300, height: 200 }],
|
||||
},
|
||||
600,
|
||||
400,
|
||||
);
|
||||
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FaceBlurSettings } from "@pien-studio/types";
|
||||
|
||||
type BlurRegion = FaceBlurSettings["regions"][number];
|
||||
|
||||
function drawPixelatedRegion(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
source: CanvasImageSource,
|
||||
sourceX: number,
|
||||
sourceY: number,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
blockSize: number,
|
||||
) {
|
||||
const sampleCanvas = document.createElement("canvas");
|
||||
sampleCanvas.width = Math.max(1, Math.round(targetWidth / blockSize));
|
||||
sampleCanvas.height = Math.max(1, Math.round(targetHeight / blockSize));
|
||||
const sampleCtx = sampleCanvas.getContext("2d");
|
||||
if (!sampleCtx) return;
|
||||
sampleCtx.imageSmoothingEnabled = false;
|
||||
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.imageSmoothingEnabled = true;
|
||||
}
|
||||
|
||||
export function renderFaceBlurRegions(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
blur: { method: FaceBlurSettings["method"]; amount: number; regions: BlurRegion[]; censorColor?: string },
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
if (!blur.regions.length) return;
|
||||
const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth);
|
||||
const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight);
|
||||
|
||||
for (const region of blur.regions) {
|
||||
const hasSourceDims = typeof region.sourceWidth === "number" && typeof region.sourceHeight === "number" && region.sourceWidth > 0 && region.sourceHeight > 0;
|
||||
const scaleX = hasSourceDims ? targetWidth / region.sourceWidth : 1;
|
||||
const scaleY = hasSourceDims ? targetHeight / region.sourceHeight : 1;
|
||||
|
||||
const x = Math.max(0, Math.floor(region.x * scaleX));
|
||||
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));
|
||||
|
||||
if (blur.method === "censor") {
|
||||
ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111";
|
||||
ctx.fillRect(x, y, w, h);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blur.method === "pixelate") {
|
||||
const pixelSize = Math.max(4, Math.round(blur.amount / 2));
|
||||
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, pixelSize);
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blur.amount}px)`;
|
||||
ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FaceDetectionOverlay, FacePreview } from "../hooks/use-face-detection";
|
||||
|
||||
export async function loadImageFromUri(uri: string): Promise<HTMLImageElement | null> {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = () => resolve();
|
||||
image.src = uri;
|
||||
});
|
||||
if (!image.naturalWidth || !image.naturalHeight) return null;
|
||||
return image;
|
||||
}
|
||||
|
||||
export function toFaceDetectionOverlays(
|
||||
faces: Array<{ x: number; y: number; width: number; height: number; gender?: string; genderScore?: number }>,
|
||||
image: HTMLImageElement,
|
||||
layerWidth?: number,
|
||||
layerHeight?: number,
|
||||
): FaceDetectionOverlay[] {
|
||||
const width = layerWidth ?? image.naturalWidth;
|
||||
const height = layerHeight ?? image.naturalHeight;
|
||||
const scaleX = width / image.naturalWidth;
|
||||
const scaleY = height / image.naturalHeight;
|
||||
|
||||
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}`;
|
||||
|
||||
return {
|
||||
x: face.x * scaleX,
|
||||
y: face.y * scaleY,
|
||||
width: face.width * scaleX,
|
||||
height: face.height * scaleY,
|
||||
sourceWidth: image.naturalWidth,
|
||||
sourceHeight: image.naturalHeight,
|
||||
label,
|
||||
gender: face.gender,
|
||||
genderScore: face.genderScore,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFacePreviews(
|
||||
image: HTMLImageElement,
|
||||
faceDetections: FaceDetectionOverlay[],
|
||||
layerWidth?: number,
|
||||
layerHeight?: number,
|
||||
): FacePreview[] {
|
||||
const width = layerWidth ?? image.naturalWidth;
|
||||
const height = layerHeight ?? image.naturalHeight;
|
||||
const toImageScaleX = image.naturalWidth / Math.max(1, width);
|
||||
const toImageScaleY = image.naturalHeight / Math.max(1, height);
|
||||
|
||||
return faceDetections
|
||||
.map((face, index) => {
|
||||
const sx = Math.max(0, Math.floor(face.x * toImageScaleX));
|
||||
const sy = Math.max(0, Math.floor(face.y * toImageScaleY));
|
||||
const sw = Math.max(1, Math.floor(face.width * toImageScaleX));
|
||||
const sh = Math.max(1, Math.floor(face.height * toImageScaleY));
|
||||
const ex = Math.min(image.naturalWidth, sx + sw);
|
||||
const ey = Math.min(image.naturalHeight, sy + sh);
|
||||
const cw = Math.max(1, ex - sx);
|
||||
const ch = Math.max(1, ey - sy);
|
||||
const canvas = document.createElement("canvas");
|
||||
const targetWidth = 84;
|
||||
const scale = targetWidth / cw;
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = Math.max(1, Math.round(ch * scale));
|
||||
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) };
|
||||
})
|
||||
.filter((preview) => preview.src);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
export type FaceBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
gender?: "male" | "female";
|
||||
genderScore?: number;
|
||||
score?: number;
|
||||
};
|
||||
|
||||
export async function detectFaceBoxes(image: HTMLImageElement): Promise<FaceBox[]> {
|
||||
const tf = await import("@tensorflow/tfjs");
|
||||
await tf.ready();
|
||||
|
||||
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"),
|
||||
]);
|
||||
|
||||
const TinyFaceDetectorOptions = (faceapi as unknown as { TinyFaceDetectorOptions: new (o: object) => object }).TinyFaceDetectorOptions;
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
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: { inputSize: number; scoreThreshold: number }
|
||||
) => 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 + b.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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { hasProjectChanged } from "./project-equality";
|
||||
|
||||
function makeProject(): Project {
|
||||
return {
|
||||
id: "p1",
|
||||
title: "Project",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
aspectRatio: "1:1",
|
||||
canvas: { width: 100, height: 100, unit: "px" },
|
||||
layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("hasProjectChanged", () => {
|
||||
it("returns false for equal projects", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
expect(hasProjectChanged(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects canvas changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
b.canvas.width = 101;
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects face blur region changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
b.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] };
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects layer order changes", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 });
|
||||
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 });
|
||||
b.layers = [b.layers[1], b.layers[0]];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats missing optional fields and undefined as equal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].name = undefined;
|
||||
b.layers[0].name = undefined;
|
||||
expect(hasProjectChanged(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects face blur removal", () => {
|
||||
const a = makeProject();
|
||||
const b = makeProject();
|
||||
a.layers[0].type = "image";
|
||||
b.layers[0].type = "image";
|
||||
a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] };
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Project } from "@pien-studio/types";
|
||||
|
||||
export function hasProjectChanged(left: Project, right: Project): boolean {
|
||||
if (left.id !== right.id) return true;
|
||||
if (left.title !== right.title) return true;
|
||||
if (left.createdAt !== right.createdAt) return true;
|
||||
if (left.updatedAt !== right.updatedAt) return true;
|
||||
if (left.aspectRatio !== right.aspectRatio) return true;
|
||||
if (left.canvas.width !== right.canvas.width || left.canvas.height !== right.canvas.height || left.canvas.unit !== right.canvas.unit) {
|
||||
return true;
|
||||
}
|
||||
if (left.layers.length !== right.layers.length) return true;
|
||||
|
||||
for (let index = 0; index < left.layers.length; index += 1) {
|
||||
const a = left.layers[index];
|
||||
const b = right.layers[index];
|
||||
if (!a || !b) return true;
|
||||
if (
|
||||
a.id !== b.id ||
|
||||
a.type !== b.type ||
|
||||
a.name !== b.name ||
|
||||
a.assetId !== b.assetId ||
|
||||
a.sourceUri !== b.sourceUri ||
|
||||
a.x !== b.x ||
|
||||
a.y !== b.y ||
|
||||
a.width !== b.width ||
|
||||
a.height !== b.height ||
|
||||
a.scale !== b.scale ||
|
||||
a.rotation !== b.rotation ||
|
||||
a.opacity !== b.opacity
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const blurA = a.faceBlur;
|
||||
const blurB = b.faceBlur;
|
||||
if (!blurA && !blurB) continue;
|
||||
if (!blurA || !blurB) return true;
|
||||
if (blurA.method !== blurB.method || blurA.amount !== blurB.amount || blurA.censorColor !== blurB.censorColor) return true;
|
||||
if (blurA.regions.length !== blurB.regions.length) return true;
|
||||
for (let regionIndex = 0; regionIndex < blurA.regions.length; regionIndex += 1) {
|
||||
const regionA = blurA.regions[regionIndex];
|
||||
const regionB = blurB.regions[regionIndex];
|
||||
if (!regionA || !regionB) return true;
|
||||
if (
|
||||
regionA.x !== regionB.x ||
|
||||
regionA.y !== regionB.y ||
|
||||
regionA.width !== regionB.width ||
|
||||
regionA.height !== regionB.height ||
|
||||
regionA.censorColor !== regionB.censorColor
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function surfaceClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white";
|
||||
}
|
||||
|
||||
export function mutedSurfaceClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#23252a]" : "border-black/10 bg-[#f7f8fa]";
|
||||
}
|
||||
|
||||
export function subtleButtonClass(isDark: boolean): string {
|
||||
return isDark
|
||||
? "border-white/15 bg-[#25272b] text-[#e8eaed]"
|
||||
: "border-black/15 bg-[#f2f4f8] text-[#1f2430]";
|
||||
}
|
||||
|
||||
export function accentButtonClass(): string {
|
||||
return "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white";
|
||||
}
|
||||
|
||||
export function hoverSubtleClass(isDark: boolean): string {
|
||||
return isDark ? "hover:bg-white/10" : "hover:bg-black/5";
|
||||
}
|
||||
|
||||
export function dividerClass(isDark: boolean): string {
|
||||
return isDark ? "bg-white/10" : "bg-black/10";
|
||||
}
|
||||
|
||||
export function panelClass(isDark: boolean): string {
|
||||
return cx("rounded border p-3", isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white");
|
||||
}
|
||||
|
||||
export function panelTitleClass(isDark: boolean): string {
|
||||
return isDark ? "text-[#dfe3ea]" : "text-[#1f2430]";
|
||||
}
|
||||
|
||||
export function panelCounterClass(isDark: boolean): string {
|
||||
return isDark ? "bg-white/10 text-[#cfd4dd]" : "bg-black/5 text-[#586071]";
|
||||
}
|
||||
|
||||
export function panelInsetClass(isDark: boolean): string {
|
||||
return isDark ? "border-white/10 bg-[#24262b]" : "border-black/10 bg-[#f6f7f9]";
|
||||
}
|
||||
Reference in New Issue
Block a user