mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: layer effects, bucket fill, simple brush
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
export type BrushOptions = {
|
||||
color: string;
|
||||
size: number;
|
||||
opacity: number;
|
||||
hardness: number; // 0–1: 0 = fully soft, 1 = hard edge
|
||||
};
|
||||
|
||||
export type BrushStroke = {
|
||||
canvas: HTMLCanvasElement;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const clean = hex.replace("#", "");
|
||||
return {
|
||||
r: parseInt(clean.slice(0, 2), 16),
|
||||
g: parseInt(clean.slice(2, 4), 16),
|
||||
b: parseInt(clean.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function drawDab(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
options: BrushOptions,
|
||||
) {
|
||||
const r = options.size / 2;
|
||||
const { r: cr, g: cg, b: cb } = hexToRgb(options.color);
|
||||
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
const innerStop = Math.max(0, Math.min(1, options.hardness));
|
||||
|
||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
||||
gradient.addColorStop(innerStop, `rgba(${cr},${cg},${cb},${options.opacity})`);
|
||||
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/** Creates a fresh stroke canvas sized to the layer. */
|
||||
export function createStroke(width: number, height: number): BrushStroke {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(width));
|
||||
canvas.height = Math.max(1, Math.round(height));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Cannot create brush canvas context");
|
||||
return { canvas, ctx, width: canvas.width, height: canvas.height };
|
||||
}
|
||||
|
||||
/** Paints a segment of a stroke from (x0,y0) to (x1,y1) using interpolated dabs. */
|
||||
export function paintSegment(
|
||||
stroke: BrushStroke,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
options: BrushOptions,
|
||||
) {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const step = Math.max(1, options.size * 0.25);
|
||||
const steps = Math.max(1, Math.ceil(dist / step));
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = steps === 0 ? 0 : i / steps;
|
||||
drawDab(stroke.ctx, x0 + dx * t, y0 + dy * t, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges stroke canvas on top of the source image and returns a data URL. */
|
||||
export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Cannot create merge canvas context"));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(image, 0, 0);
|
||||
// Scale stroke canvas to match image natural size
|
||||
ctx.drawImage(stroke.canvas, 0, 0, canvas.width, canvas.height);
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = sourceUri;
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ describe("buildFaceLabelOverlays", () => {
|
||||
const layers = [
|
||||
{
|
||||
id: "layer-1",
|
||||
type: "image" as const,
|
||||
type: "raster" as const,
|
||||
x: 20,
|
||||
y: 30,
|
||||
width: 180,
|
||||
@@ -19,6 +19,8 @@ describe("buildFaceLabelOverlays", () => {
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
effects: [],
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function buildFaceLabelOverlays(
|
||||
const layer = layers.find((item) => item.id === faceOverlayLayerId);
|
||||
if (!layer) return [];
|
||||
|
||||
const isImage = layer.type === "image";
|
||||
const isImage = layer.type === "raster";
|
||||
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 [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EditorToolId } from "../store/editor-store";
|
||||
import type { EditorToolId } from "@pien-studio/editor-core";
|
||||
|
||||
export type ToolModeController = {
|
||||
kind: "mode";
|
||||
@@ -22,6 +22,8 @@ type CreateEditorToolControllersOptions = {
|
||||
pointer: string;
|
||||
pan: string;
|
||||
face: string;
|
||||
fill: string;
|
||||
brush: string;
|
||||
text: string;
|
||||
image: string;
|
||||
};
|
||||
@@ -32,6 +34,8 @@ export function createEditorToolControllers(options: CreateEditorToolControllers
|
||||
{ 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: "mode", id: "fill", label: options.labels.fill },
|
||||
{ kind: "mode", id: "brush", label: options.labels.brush },
|
||||
{ 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,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { faceBlurRenderer } from "./face-blur";
|
||||
import type { FaceBlurEffect } from "@pien-studio/types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function makeContext2d(ctx: CanvasRenderingContext2D, image: HTMLImageElement, tw = 600, th = 400) {
|
||||
return { ctx, image, targetWidth: tw, targetHeight: th };
|
||||
}
|
||||
|
||||
describe("faceBlurRenderer.render (regions)", () => {
|
||||
it("renders gaussian blur region with source dimensions", () => {
|
||||
const ctx = makeContext();
|
||||
const image = makeImage();
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "pixelate",
|
||||
amount: 10,
|
||||
regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
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();
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "censor",
|
||||
amount: 20,
|
||||
censorColor: "#ff0000",
|
||||
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
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);
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 16,
|
||||
regions: [{ x: 100, y: 120, width: 300, height: 200 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("faceBlurRenderer.renderLayer", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.renderLayer(makeContext2d(ctx, image), effect);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const effect: FaceBlurEffect = {
|
||||
kind: "face-blur",
|
||||
enabled: true,
|
||||
method: "gaussian",
|
||||
amount: 24,
|
||||
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
|
||||
};
|
||||
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FaceBlurSettings } from "@pien-studio/types";
|
||||
|
||||
type BlurRegion = FaceBlurSettings["regions"][number];
|
||||
import type { FaceBlurEffect } from "@pien-studio/types";
|
||||
import type { EffectRenderer, EffectRenderContext } from "./types";
|
||||
|
||||
function drawPixelatedRegion(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
@@ -21,36 +20,12 @@ 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,
|
||||
@@ -69,86 +44,36 @@ function drawBlurredRegionFallback(
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
);
|
||||
ctx.drawImage(regionCanvas, 0, 0, regionCanvas.width, regionCanvas.height, targetX, targetY, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
export function renderFaceBlurRegions(
|
||||
function renderRegions(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
blur: {
|
||||
method: FaceBlurSettings["method"];
|
||||
amount: number;
|
||||
regions: BlurRegion[];
|
||||
censorColor?: string;
|
||||
},
|
||||
effect: FaceBlurEffect,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
if (!blur.regions.length) return;
|
||||
) {
|
||||
if (!effect.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) {
|
||||
for (const region of effect.regions) {
|
||||
const sourceWidth = region.sourceWidth ?? 0;
|
||||
const sourceHeight = region.sourceHeight ?? 0;
|
||||
const hasSourceDims = sourceWidth > 0 && sourceHeight > 0;
|
||||
@@ -159,80 +84,42 @@ 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";
|
||||
if (effect.method === "censor") {
|
||||
ctx.fillStyle = region.censorColor ?? effect.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);
|
||||
if (effect.method === "pixelate") {
|
||||
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, Math.max(4, Math.round(effect.amount / 2)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (canUseCanvasFilter(ctx)) {
|
||||
if ("filter" in ctx && typeof ctx.filter === "string") {
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blur.amount}px)`;
|
||||
ctx.filter = `blur(${effect.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,
|
||||
);
|
||||
drawBlurredRegionFallback(ctx, image, sx0, sy0, sw, sh, x, y, w, h, effect.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) {
|
||||
function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
|
||||
const { ctx, image, targetWidth, targetHeight } = context;
|
||||
if (!effect.regions.length) {
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
const canBlurAtSourceSize = blur.regions.every(
|
||||
(region) => (region.sourceWidth ?? 0) > 0 && (region.sourceHeight ?? 0) > 0,
|
||||
);
|
||||
if (!canBlurAtSourceSize) {
|
||||
const allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0);
|
||||
if (!allHaveSourceDims) {
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
renderFaceBlurRegions(ctx, image, blur, targetWidth, targetHeight);
|
||||
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,24 +131,15 @@ export function renderImageWithFaceBlur(
|
||||
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,
|
||||
);
|
||||
renderRegions(sourceCtx, image, effect, sourceCanvas.width, sourceCanvas.height);
|
||||
ctx.drawImage(sourceCanvas, 0, 0, sourceCanvas.width, sourceCanvas.height, 0, 0, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
export const faceBlurRenderer: EffectRenderer<FaceBlurEffect> = {
|
||||
kind: "face-blur",
|
||||
render: ({ ctx, image, targetWidth, targetHeight }, effect) => {
|
||||
renderRegions(ctx, image, effect, targetWidth, targetHeight);
|
||||
},
|
||||
renderLayer,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LayerEffect } from "@pien-studio/types";
|
||||
import type { EffectRenderer, EffectRenderContext } from "./types";
|
||||
import { faceBlurRenderer } from "./face-blur";
|
||||
|
||||
const renderers: EffectRenderer[] = [faceBlurRenderer];
|
||||
|
||||
const effectRendererRegistry = new Map<string, EffectRenderer>(
|
||||
renderers.map((r) => [r.kind, r]),
|
||||
);
|
||||
|
||||
export function getEffectRenderer(kind: string): EffectRenderer | undefined {
|
||||
return effectRendererRegistry.get(kind);
|
||||
}
|
||||
|
||||
/** Draws a layer image applying all its effects in order. */
|
||||
export function renderLayerWithEffects(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
effects: LayerEffect[],
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
): void {
|
||||
const activeEffects = effects.filter((e) => {
|
||||
if (e.enabled === false) return false;
|
||||
return effectRendererRegistry.get(e.kind) !== undefined;
|
||||
});
|
||||
|
||||
if (activeEffects.length === 0) {
|
||||
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
const context: EffectRenderContext = { ctx, image, targetWidth, targetHeight };
|
||||
|
||||
// The first effect owns the full layer render (draws base image + applies itself)
|
||||
const first = activeEffects[0];
|
||||
const firstRenderer = effectRendererRegistry.get(first.kind);
|
||||
firstRenderer?.renderLayer(context, first as never);
|
||||
|
||||
// Subsequent effects render on top (overlay only, no re-draw of base)
|
||||
for (let i = 1; i < activeEffects.length; i++) {
|
||||
const effect = activeEffects[i];
|
||||
const renderer = effectRendererRegistry.get(effect.kind);
|
||||
renderer?.render(context, effect as never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayerEffect } from "@pien-studio/types";
|
||||
|
||||
export type EffectRenderContext = {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
image: HTMLImageElement;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
};
|
||||
|
||||
export type EffectRenderer<T extends LayerEffect = LayerEffect> = {
|
||||
kind: T["kind"];
|
||||
/** Renders the effect onto the canvas. Called after the base image is drawn. */
|
||||
render: (context: EffectRenderContext, effect: T) => void;
|
||||
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
|
||||
renderLayer: (context: EffectRenderContext, effect: T) => void;
|
||||
};
|
||||
+11
-30
@@ -1,5 +1,5 @@
|
||||
import type { Layer, Project } from "@pien-studio/types";
|
||||
import { renderImageWithFaceBlur } from "./face-blur-renderer";
|
||||
import { renderLayerWithEffects } from "./effects/registry";
|
||||
|
||||
type ExportOptions = {
|
||||
isDark: boolean;
|
||||
@@ -20,11 +20,7 @@ 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);
|
||||
@@ -49,24 +45,15 @@ function drawFallbackLayer(
|
||||
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 === "raster" ? Math.round(200 * layer.scale) : 120);
|
||||
const height = layer.height ?? (layer.type === "raster" ? Math.round(150 * layer.scale) : 40);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clampOpacity(layer.opacity);
|
||||
@@ -74,10 +61,10 @@ async function drawLayer(
|
||||
ctx.rotate((layer.rotation * Math.PI) / 180);
|
||||
ctx.translate(-width / 2, -height / 2);
|
||||
|
||||
if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) {
|
||||
if ((layer.type === "raster" || layer.type === "sticker") && layer.sourceUri) {
|
||||
try {
|
||||
const image = await loadImage(layer.sourceUri);
|
||||
renderImageWithFaceBlur(ctx, image, layer.faceBlur, width, height);
|
||||
renderLayerWithEffects(ctx, image, layer.effects, width, height);
|
||||
} catch {
|
||||
drawFallbackLayer(ctx, layer, isDark);
|
||||
}
|
||||
@@ -88,14 +75,8 @@ async function drawLayer(
|
||||
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;
|
||||
@@ -103,10 +84,10 @@ export async function exportProjectAsPng(
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Cannot create export canvas context");
|
||||
|
||||
ctx.scale(pixelRatio, pixelRatio);
|
||||
|
||||
for (const layer of project.layers) {
|
||||
if (layer.visible === false) continue;
|
||||
await drawLayer(ctx, layer, options.isDark);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
renderFaceBlurRegions,
|
||||
renderImageWithFaceBlur,
|
||||
} 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,
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
type RGBA = [number, number, number, number];
|
||||
|
||||
function colorDistance(a: RGBA, b: RGBA): number {
|
||||
// Weight alpha at 25% so transparent regions fill correctly
|
||||
return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]) + Math.abs(a[3] - b[3]) * 0.25;
|
||||
}
|
||||
|
||||
function matchesTarget(pixel: RGBA, target: RGBA, tolerance: number): boolean {
|
||||
// Fully transparent pixels all match each other regardless of RGB
|
||||
if (target[3] === 0) return pixel[3] <= tolerance;
|
||||
return colorDistance(pixel, target) <= tolerance;
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string): RGBA {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
const a = clean.length === 8 ? parseInt(clean.slice(6, 8), 16) : 255;
|
||||
return [r, g, b, a];
|
||||
}
|
||||
|
||||
export function floodFillDataUrl(
|
||||
sourceUri: string,
|
||||
px: number,
|
||||
py: number,
|
||||
fillColor: string,
|
||||
tolerance: number = 32,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
reject(new Error("Cannot get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const { width, height } = canvas;
|
||||
const data = ctx.getImageData(0, 0, width, height);
|
||||
const pixels = data.data;
|
||||
|
||||
const x = Math.round(px);
|
||||
const y = Math.round(py);
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) {
|
||||
resolve(sourceUri);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = (y * width + x) * 4;
|
||||
const target: RGBA = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
|
||||
const fill = hexToRgba(fillColor);
|
||||
|
||||
if (matchesTarget(target, fill, 0)) {
|
||||
resolve(sourceUri);
|
||||
return;
|
||||
}
|
||||
|
||||
const visited = new Uint8Array(width * height);
|
||||
const stack: number[] = [x + y * width];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const pos = stack.pop()!;
|
||||
if (visited[pos]) continue;
|
||||
visited[pos] = 1;
|
||||
|
||||
const cx = pos % width;
|
||||
const cy = Math.floor(pos / width);
|
||||
const ci = pos * 4;
|
||||
const current: RGBA = [pixels[ci], pixels[ci + 1], pixels[ci + 2], pixels[ci + 3]];
|
||||
|
||||
if (!matchesTarget(current, target, tolerance)) continue;
|
||||
|
||||
pixels[ci] = fill[0];
|
||||
pixels[ci + 1] = fill[1];
|
||||
pixels[ci + 2] = fill[2];
|
||||
pixels[ci + 3] = fill[3];
|
||||
|
||||
if (cx > 0) stack.push(pos - 1);
|
||||
if (cx < width - 1) stack.push(pos + 1);
|
||||
if (cy > 0) stack.push(pos - width);
|
||||
if (cy < height - 1) stack.push(pos + width);
|
||||
}
|
||||
|
||||
ctx.putImageData(data, 0, 0);
|
||||
resolve(canvas.toDataURL("image/png"));
|
||||
};
|
||||
image.onerror = reject;
|
||||
image.src = sourceUri;
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ function makeProject(): Project {
|
||||
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 }],
|
||||
layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,18 +31,18 @@ describe("hasProjectChanged", () => {
|
||||
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 }] };
|
||||
a.layers[0].type = "raster";
|
||||
b.layers[0].type = "raster";
|
||||
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
|
||||
b.layers[0].effects = [{ kind: "face-blur", enabled: true, 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 });
|
||||
a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
|
||||
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
|
||||
b.layers = [b.layers[1], b.layers[0]];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
@@ -58,9 +58,9 @@ describe("hasProjectChanged", () => {
|
||||
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 }] };
|
||||
a.layers[0].type = "raster";
|
||||
b.layers[0].type = "raster";
|
||||
a.layers[0].effects = [{ kind: "face-blur", enabled: true, method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }];
|
||||
expect(hasProjectChanged(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,26 +32,7 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ToolUiDefinition } from "./types";
|
||||
|
||||
const definitions: ToolUiDefinition[] = [
|
||||
{
|
||||
id: "pointer",
|
||||
interactionMode: "select",
|
||||
allowsLayerDrag: true,
|
||||
allowsLayerResize: true,
|
||||
allowsLayerRotate: true,
|
||||
iconName: "MousePointer2",
|
||||
cursor: "default",
|
||||
labelKey: "editor.toolPointer",
|
||||
},
|
||||
{
|
||||
id: "hand",
|
||||
interactionMode: "pan",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "Hand",
|
||||
cursor: "grab",
|
||||
labelKey: "editor.toolPan",
|
||||
},
|
||||
{
|
||||
id: "face",
|
||||
interactionMode: "annotate",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "ScanFace",
|
||||
cursor: "default",
|
||||
labelKey: "editor.toolFace",
|
||||
},
|
||||
{
|
||||
id: "fill",
|
||||
interactionMode: "paint",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "PaintBucket",
|
||||
cursor: "crosshair",
|
||||
labelKey: "editor.toolFill",
|
||||
},
|
||||
{
|
||||
id: "brush",
|
||||
interactionMode: "paint",
|
||||
allowsLayerDrag: false,
|
||||
allowsLayerResize: false,
|
||||
allowsLayerRotate: false,
|
||||
iconName: "Brush",
|
||||
cursor: "crosshair",
|
||||
labelKey: "editor.toolBrush",
|
||||
},
|
||||
];
|
||||
|
||||
const toolUiRegistry = new Map<string, ToolUiDefinition>(
|
||||
definitions.map((d) => [d.id, d]),
|
||||
);
|
||||
|
||||
export function getToolUiDefinition(id: string): ToolUiDefinition | undefined {
|
||||
return toolUiRegistry.get(id);
|
||||
}
|
||||
|
||||
export function getAllToolUiDefinitions(): ToolUiDefinition[] {
|
||||
return definitions;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ToolDefinition } from "@pien-studio/editor-core";
|
||||
|
||||
export type ToolUiDefinition = ToolDefinition & {
|
||||
/** Lucide icon component name (resolved at render time) */
|
||||
iconName: string;
|
||||
/** CSS cursor when this tool is active */
|
||||
cursor: string;
|
||||
/** i18n key for the toolbar label */
|
||||
labelKey: string;
|
||||
};
|
||||
Reference in New Issue
Block a user