feat: layer effects, bucket fill, simple brush

This commit is contained in:
2026-05-21 00:02:12 +07:00
parent bcf4650c70
commit e7dd9420e7
47 changed files with 1675 additions and 1038 deletions
+176
View File
@@ -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();
});
});
+145
View File
@@ -0,0 +1,145 @@
import type { FaceBlurEffect } from "@pien-studio/types";
import type { EffectRenderer, EffectRenderContext } from "./types";
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;
}
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);
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);
}
function renderRegions(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
effect: FaceBlurEffect,
targetWidth: number,
targetHeight: number,
) {
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 effect.regions) {
const sourceWidth = region.sourceWidth ?? 0;
const sourceHeight = region.sourceHeight ?? 0;
const hasSourceDims = sourceWidth > 0 && sourceHeight > 0;
const scaleX = hasSourceDims ? targetWidth / sourceWidth : 1;
const scaleY = hasSourceDims ? targetHeight / 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 (effect.method === "censor") {
ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111";
ctx.fillRect(x, y, w, h);
continue;
}
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 ("filter" in ctx && typeof ctx.filter === "string") {
ctx.save();
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, effect.amount);
}
}
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 allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0);
if (!allHaveSourceDims) {
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
renderRegions(ctx, image, effect, 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);
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,
};
+46
View File
@@ -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);
}
}
+16
View File
@@ -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;
};