♻️ refactor!: massive refactor

This commit is contained in:
2026-05-31 03:04:30 +07:00
parent c1f12c7201
commit df3c944547
86 changed files with 3691 additions and 1129 deletions
+15 -3
View File
@@ -9,13 +9,22 @@ describe("brush painter pure helpers", () => {
});
it("clamps invalid brush options", () => {
expect(clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 })).toEqual({
expect(
clampBrushOptions({ color: "#fff", size: 0, opacity: 2, hardness: -1 }),
).toEqual({
color: "#fff",
size: 1,
opacity: 1,
hardness: 0,
});
expect(clampBrushOptions({ color: "#000", size: Number.NaN, opacity: Number.NaN, hardness: Number.NaN })).toEqual({
expect(
clampBrushOptions({
color: "#000",
size: Number.NaN,
opacity: Number.NaN,
hardness: Number.NaN,
}),
).toEqual({
color: "#000",
size: 1,
opacity: 1,
@@ -31,6 +40,9 @@ describe("brush painter pure helpers", () => {
{ x: 3, y: 0 },
{ x: 4, y: 0 },
]);
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([{ x: 2, y: 3 }, { x: 2, y: 3 }]);
expect(buildBrushDabs(2, 3, 2, 3, 10)).toEqual([
{ x: 2, y: 3 },
{ x: 2, y: 3 },
]);
});
});
+24 -10
View File
@@ -2,7 +2,7 @@ export type BrushOptions = {
color: string;
size: number;
opacity: number;
hardness: number; // 01: 0 = fully soft, 1 = hard edge
hardness: number; // 0 to 1: 0 is fully soft, 1 is a hard edge.
};
export type BrushStroke = {
@@ -31,12 +31,24 @@ export function clampBrushOptions(options: BrushOptions): BrushOptions {
return {
color: options.color,
size: Math.max(1, Number.isFinite(options.size) ? options.size : 1),
opacity: Math.max(0, Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1)),
hardness: Math.max(0, Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1)),
opacity: Math.max(
0,
Math.min(1, Number.isFinite(options.opacity) ? options.opacity : 1),
),
hardness: Math.max(
0,
Math.min(1, Number.isFinite(options.hardness) ? options.hardness : 1),
),
};
}
export function buildBrushDabs(x0: number, y0: number, x1: number, y1: number, size: number): BrushDab[] {
export function buildBrushDabs(
x0: number,
y0: number,
x1: number,
y1: number,
size: number,
): BrushDab[] {
const dx = x1 - x0;
const dy = y1 - y0;
const dist = Math.sqrt(dx * dx + dy * dy);
@@ -65,7 +77,10 @@ function drawDab(
const gradient = ctx.createRadialGradient(x, y, 0, x, y, r);
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
gradient.addColorStop(normalized.hardness, `rgba(${cr},${cg},${cb},${normalized.opacity})`);
gradient.addColorStop(
normalized.hardness,
`rgba(${cr},${cg},${cb},${normalized.opacity})`,
);
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.beginPath();
@@ -74,7 +89,6 @@ function drawDab(
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));
@@ -84,7 +98,6 @@ export function createStroke(width: number, height: number): BrushStroke {
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,
@@ -99,8 +112,10 @@ export function paintSegment(
}
}
/** Merges stroke canvas on top of the source image and returns a data URL. */
export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<string> {
export function commitStroke(
sourceUri: string,
stroke: BrushStroke,
): Promise<string> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
@@ -114,7 +129,6 @@ export function commitStroke(sourceUri: string, stroke: BrushStroke): Promise<st
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"));
};
+7 -1
View File
@@ -3,7 +3,12 @@ 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 });
const overlays = buildFaceLabelOverlays(
[],
"missing",
[{ x: 10, y: 10, width: 20, height: 20 }],
{ x: 0, y: 0, scale: 1 },
);
expect(overlays).toEqual([]);
});
@@ -12,6 +17,7 @@ describe("buildFaceLabelOverlays", () => {
{
id: "layer-1",
type: "raster" as const,
asset: null,
x: 20,
y: 30,
width: 180,
+19 -7
View File
@@ -1,6 +1,12 @@
import type { Layer } from "@pien-studio/types";
type FaceDetection = { x: number; y: number; width: number; height: number; label?: string };
type FaceDetection = {
x: number;
y: number;
width: number;
height: number;
label?: string;
};
type Viewport = { x: number; y: number; scale: number };
@@ -13,9 +19,8 @@ export function buildFaceLabelOverlays(
const layer = layers.find((item) => item.id === faceOverlayLayerId);
if (!layer) return [];
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);
const layerWidth = layer.width;
const layerHeight = layer.height;
if (!layerWidth || !layerHeight) return [];
const centerX = layer.x + layerWidth / 2;
@@ -23,7 +28,12 @@ export function buildFaceLabelOverlays(
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 }> = [];
const placed: Array<{
left: number;
top: number;
width: number;
height: number;
}> = [];
return faceDetections.map((face, index) => {
const worldX = layer.x + face.x;
@@ -41,8 +51,10 @@ export function buildFaceLabelOverlays(
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;
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;
})
) {
+204 -34
View File
@@ -18,7 +18,12 @@ function makeImage(width = 1200, height = 800) {
return { naturalWidth: width, naturalHeight: height } as HTMLImageElement;
}
function makeContext2d(ctx: CanvasRenderingContext2D, image: HTMLImageElement, tw = 600, th = 400) {
function makeContext2d(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
tw = 600,
th = 400,
) {
return { ctx, image, targetWidth: tw, targetHeight: th };
}
@@ -31,13 +36,32 @@ describe("faceBlurRenderer.render (regions)", () => {
enabled: true,
method: "gaussian",
amount: 24,
regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }],
regions: [
{
x: 120,
y: 80,
width: 300,
height: 200,
sourceWidth: 1200,
sourceHeight: 800,
},
],
};
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.drawImage).toHaveBeenCalledWith(
image,
120,
80,
300,
200,
60,
40,
150,
100,
);
expect(ctx.restore).toHaveBeenCalledOnce();
});
@@ -48,27 +72,65 @@ describe("faceBlurRenderer.render (regions)", () => {
const ctx = makeContext();
const image = makeImage();
const sampleDrawImage = vi.fn();
const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D;
const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement;
const sampleCtx = {
imageSmoothingEnabled: true,
drawImage: sampleDrawImage,
} as unknown as CanvasRenderingContext2D;
const sampleCanvas = {
width: 0,
height: 0,
getContext: vi.fn(() => sampleCtx),
} as unknown as HTMLCanvasElement;
const nativeCreateElement = doc.createElement.bind(doc);
const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => {
if (tagName === "canvas") return sampleCanvas;
return nativeCreateElement(tagName);
});
const createElement = vi
.spyOn(doc, "createElement")
.mockImplementation((tagName: string) => {
if (tagName === "canvas") return sampleCanvas;
return nativeCreateElement(tagName);
});
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 }],
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);
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();
});
@@ -81,7 +143,17 @@ describe("faceBlurRenderer.render (regions)", () => {
method: "censor",
amount: 20,
censorColor: "#ff0000",
regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }],
regions: [
{
x: 20,
y: 30,
width: 40,
height: 50,
sourceWidth: 1200,
sourceHeight: 800,
censorColor: "#00ff00",
},
],
};
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
@@ -101,7 +173,17 @@ describe("faceBlurRenderer.render (regions)", () => {
};
faceBlurRenderer.render(makeContext2d(ctx, image), effect);
expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200);
expect(ctx.drawImage).toHaveBeenCalledWith(
image,
400,
480,
1200,
800,
100,
120,
300,
200,
);
});
});
@@ -113,28 +195,67 @@ describe("faceBlurRenderer.renderLayer", () => {
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 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 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 }],
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);
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();
});
@@ -147,30 +268,79 @@ describe("faceBlurRenderer.renderLayer", () => {
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 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 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 }],
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);
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();
});
});
+125 -15
View File
@@ -20,9 +20,29 @@ 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;
}
@@ -44,7 +64,17 @@ 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));
@@ -55,11 +85,41 @@ function drawBlurredRegionFallback(
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);
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,
);
}
function renderRegions(
@@ -84,10 +144,18 @@ function renderRegions(
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 (effect.method === "censor") {
ctx.fillStyle = region.censorColor ?? effect.censorColor ?? "#111111";
@@ -95,7 +163,19 @@ function renderRegions(
continue;
}
if (effect.method === "pixelate") {
drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, Math.max(4, Math.round(effect.amount / 2)));
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") {
@@ -105,7 +185,19 @@ function renderRegions(
ctx.restore();
continue;
}
drawBlurredRegionFallback(ctx, image, sx0, sy0, sw, sh, x, y, w, h, effect.amount);
drawBlurredRegionFallback(
ctx,
image,
sx0,
sy0,
sw,
sh,
x,
y,
w,
h,
effect.amount,
);
}
}
@@ -116,7 +208,9 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
return;
}
const allHaveSourceDims = effect.regions.every((r) => (r.sourceWidth ?? 0) > 0 && (r.sourceHeight ?? 0) > 0);
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);
@@ -132,8 +226,24 @@ function renderLayer(context: EffectRenderContext, effect: FaceBlurEffect) {
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);
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 = {
+7 -4
View File
@@ -12,7 +12,6 @@ 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,
@@ -30,14 +29,18 @@ export function renderLayerWithEffects(
return;
}
const context: EffectRenderContext = { ctx, image, targetWidth, targetHeight };
const context: EffectRenderContext = {
ctx,
image,
targetWidth,
targetHeight,
};
// The first effect owns the full layer render (draws base image + applies itself)
// The first effect owns the base image draw; later effects only overlay their changes.
const first = activeEffects[0];
const firstRenderer = effectRendererRegistry.get(first.kind);
firstRenderer?.renderLayer(context, first);
// 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);
-2
View File
@@ -9,8 +9,6 @@ export type EffectRenderContext = {
export type EffectRenderer = {
kind: LayerEffect["kind"];
/** Renders the effect onto the canvas. Called after the base image is drawn. */
render: (context: EffectRenderContext, effect: LayerEffect) => void;
/** Renders the full layer (image + effect). Called instead of a plain drawImage. */
renderLayer: (context: EffectRenderContext, effect: LayerEffect) => void;
};
+32 -12
View File
@@ -1,4 +1,8 @@
import type { Layer, Project } from "@pien-studio/types";
import {
getLayerRuntimeSource,
type Layer,
type Project,
} from "@pien-studio/types";
import { renderLayerWithEffects } from "./effects/registry";
type ExportOptions = {
@@ -20,10 +24,14 @@ 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);
const width = Math.max(80, layer.width);
const height = Math.max(34, layer.height);
const radius = 8;
ctx.beginPath();
@@ -45,15 +53,21 @@ function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark:
ctx.stroke();
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
ctx.font =
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, width / 2, height / 2);
}
async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) {
const width = layer.width ?? (layer.type === "raster" ? Math.round(200 * layer.scale) : 120);
const height = layer.height ?? (layer.type === "raster" ? Math.round(150 * layer.scale) : 40);
async function drawLayer(
ctx: CanvasRenderingContext2D,
layer: Layer,
isDark: boolean,
) {
const width = layer.width;
const height = layer.height;
const sourceUri = getLayerRuntimeSource(layer);
ctx.save();
ctx.globalAlpha = clampOpacity(layer.opacity);
@@ -61,9 +75,9 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
ctx.rotate((layer.rotation * Math.PI) / 180);
ctx.translate(-width / 2, -height / 2);
if ((layer.type === "raster" || layer.type === "sticker") && layer.sourceUri) {
if ((layer.type === "raster" || layer.type === "sticker") && sourceUri) {
try {
const image = await loadImage(layer.sourceUri);
const image = await loadImage(sourceUri);
renderLayerWithEffects(ctx, image, layer.effects, width, height);
} catch {
drawFallbackLayer(ctx, layer, isDark);
@@ -75,8 +89,14 @@ async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: bo
ctx.restore();
}
export async function exportProjectAsPng(project: Project, options: ExportOptions) {
const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1));
export async function exportProjectAsPng(
project: Project,
options: ExportOptions,
) {
const pixelRatio = Math.max(
1,
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
);
const { width, height } = project.canvas;
const canvas = document.createElement("canvas");
canvas.width = width * pixelRatio;
+18 -3
View File
@@ -2,7 +2,12 @@ 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;
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 {
@@ -53,7 +58,12 @@ export function floodFillDataUrl(
}
const idx = (y * width + x) * 4;
const target: RGBA = [pixels[idx], pixels[idx + 1], pixels[idx + 2], pixels[idx + 3]];
const target: RGBA = [
pixels[idx],
pixels[idx + 1],
pixels[idx + 2],
pixels[idx + 3],
];
const fill = hexToRgba(fillColor);
if (matchesTarget(target, fill, 0)) {
@@ -72,7 +82,12 @@ export function floodFillDataUrl(
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]];
const current: RGBA = [
pixels[ci],
pixels[ci + 1],
pixels[ci + 2],
pixels[ci + 3],
];
if (!matchesTarget(current, target, tolerance)) continue;
+71 -10
View File
@@ -10,7 +10,44 @@ 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, effects: [], visible: true }],
layers: [textLayer("l1")],
};
}
function textLayer(id: string) {
return {
id,
type: "text" as const,
x: 0,
y: 0,
width: 120,
height: 48,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
text: "Text",
fontFamily: "system-ui",
fontSize: 24,
color: "#000",
};
}
function rasterLayer(id: string) {
return {
id,
type: "raster" as const,
x: 0,
y: 0,
width: 120,
height: 80,
scale: 1,
rotation: 0,
opacity: 1,
effects: [],
visible: true,
asset: null,
};
}
@@ -31,18 +68,34 @@ describe("hasProjectChanged", () => {
it("detects face blur region changes", () => {
const a = makeProject();
const b = makeProject();
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 }] }];
a.layers[0] = rasterLayer("l1");
b.layers[0] = rasterLayer("l1");
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, effects: [], visible: true });
b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1, effects: [], visible: true });
a.layers.push({ ...textLayer("l2"), x: 3, y: 4 });
b.layers.push({ ...textLayer("l2"), x: 3, y: 4 });
b.layers = [b.layers[1], b.layers[0]];
expect(hasProjectChanged(a, b)).toBe(true);
});
@@ -65,9 +118,17 @@ describe("hasProjectChanged", () => {
it("detects face blur removal", () => {
const a = makeProject();
const b = makeProject();
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 }] }];
a.layers[0] = rasterLayer("l1");
b.layers[0] = rasterLayer("l1");
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);
});
});
+27 -4
View File
@@ -1,4 +1,4 @@
import type { Project } from "@pien-studio/types";
import { getLayerAssetRef, type Project } from "@pien-studio/types";
export function hasProjectChanged(left: Project, right: Project): boolean {
if (left.id !== right.id) return true;
@@ -6,7 +6,11 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
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) {
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;
@@ -19,8 +23,6 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
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 ||
@@ -33,6 +35,27 @@ export function hasProjectChanged(left: Project, right: Project): boolean {
return true;
}
if (
JSON.stringify(getLayerAssetRef(a)) !==
JSON.stringify(getLayerAssetRef(b))
)
return true;
if (
(a.type === "raster" || a.type === "sticker") &&
(b.type === "raster" || b.type === "sticker") &&
a.runtimeSourceUri !== b.runtimeSourceUri
)
return true;
if (
a.type === "text" &&
b.type === "text" &&
(a.text !== b.text ||
a.fontFamily !== b.fontFamily ||
a.fontSize !== b.fontSize ||
a.color !== b.color)
)
return true;
if (JSON.stringify(a.effects) !== JSON.stringify(b.effects)) return true;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Project } from "@pien-studio/types";
import {
cleanupOrphanAssets,
deleteProject,
duplicateProject,
getProjectById,
loadProjects,
releaseProjectObjectUrls,
upsertProject,
} from "@pien-studio/storage";
export type ProjectRepository = {
listProjects: () => Promise<Project[]>;
getProject: (projectId: string) => Promise<Project | null>;
upsertProject: (project: Project) => Promise<void>;
deleteProject: (projectId: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<Project | null>;
cleanupAssets: () => Promise<number>;
releaseObjectUrls: (
project: Project,
keepAssetIds?: Iterable<string>,
) => void;
};
export const localProjectRepository: ProjectRepository = {
listProjects: loadProjects,
getProject: getProjectById,
upsertProject,
deleteProject,
duplicateProject,
cleanupAssets: cleanupOrphanAssets,
releaseObjectUrls: releaseProjectObjectUrls,
};
+10 -3
View File
@@ -7,7 +7,9 @@ export function surfaceClass(isDark: boolean): string {
}
export function mutedSurfaceClass(isDark: boolean): string {
return isDark ? "border-white/10 bg-[#23252a]" : "border-black/10 bg-[#f7f8fa]";
return isDark
? "border-white/10 bg-[#23252a]"
: "border-black/10 bg-[#f7f8fa]";
}
export function subtleButtonClass(isDark: boolean): string {
@@ -29,7 +31,10 @@ export function dividerClass(isDark: boolean): string {
}
export function panelClass(isDark: boolean): string {
return cx("rounded border p-3", isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white");
return cx(
"rounded border p-3",
isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white",
);
}
export function panelTitleClass(isDark: boolean): string {
@@ -41,5 +46,7 @@ export function panelCounterClass(isDark: boolean): string {
}
export function panelInsetClass(isDark: boolean): string {
return isDark ? "border-white/10 bg-[#24262b]" : "border-black/10 bg-[#f6f7f9]";
return isDark
? "border-white/10 bg-[#24262b]"
: "border-black/10 bg-[#f6f7f9]";
}
-3
View File
@@ -1,10 +1,7 @@
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;
};