Files
Pien-Studio/apps/web/lib/export-png.ts
T

122 lines
3.3 KiB
TypeScript
Raw Normal View History

2026-05-31 03:03:49 +07:00
import {
getLayerRuntimeSource,
type Layer,
type Project,
} from "@pien-studio/types";
2026-05-21 00:02:12 +07:00
import { renderLayerWithEffects } from "./effects/registry";
2026-05-10 03:06:54 +07:00
type ExportOptions = {
isDark: boolean;
pixelRatio?: number;
};
function clampOpacity(value: number | undefined) {
if (typeof value !== "number" || Number.isNaN(value)) return 1;
return Math.max(0, Math.min(1, value));
}
function loadImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = src;
});
}
2026-05-31 03:03:49 +07:00
function drawFallbackLayer(
ctx: CanvasRenderingContext2D,
layer: Layer,
isDark: boolean,
) {
2026-05-10 03:06:54 +07:00
const text = layer.name ?? layer.type;
2026-05-31 03:03:49 +07:00
const width = Math.max(80, layer.width);
const height = Math.max(34, layer.height);
2026-05-10 03:06:54 +07:00
const radius = 8;
ctx.beginPath();
ctx.moveTo(radius, 0);
ctx.lineTo(width - radius, 0);
ctx.quadraticCurveTo(width, 0, width, radius);
ctx.lineTo(width, height - radius);
ctx.quadraticCurveTo(width, height, width - radius, height);
ctx.lineTo(radius, height);
ctx.quadraticCurveTo(0, height, 0, height - radius);
ctx.lineTo(0, radius);
ctx.quadraticCurveTo(0, 0, radius, 0);
ctx.closePath();
ctx.fillStyle = isDark ? "#2d3036" : "#ffffff";
ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)";
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
2026-05-31 03:03:49 +07:00
ctx.font =
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
2026-05-10 03:06:54 +07:00
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, width / 2, height / 2);
}
2026-05-31 03:03:49 +07:00
async function drawLayer(
ctx: CanvasRenderingContext2D,
layer: Layer,
isDark: boolean,
) {
const width = layer.width;
const height = layer.height;
const sourceUri = getLayerRuntimeSource(layer);
2026-05-10 03:06:54 +07:00
ctx.save();
ctx.globalAlpha = clampOpacity(layer.opacity);
ctx.translate(layer.x + width / 2, layer.y + height / 2);
ctx.rotate((layer.rotation * Math.PI) / 180);
ctx.translate(-width / 2, -height / 2);
2026-05-31 03:03:49 +07:00
if ((layer.type === "raster" || layer.type === "sticker") && sourceUri) {
2026-05-10 03:06:54 +07:00
try {
2026-05-31 03:03:49 +07:00
const image = await loadImage(sourceUri);
2026-05-21 00:02:12 +07:00
renderLayerWithEffects(ctx, image, layer.effects, width, height);
2026-05-10 03:06:54 +07:00
} catch {
drawFallbackLayer(ctx, layer, isDark);
}
} else {
drawFallbackLayer(ctx, layer, isDark);
}
ctx.restore();
}
2026-05-31 03:03:49 +07:00
export async function exportProjectAsPng(
project: Project,
options: ExportOptions,
) {
const pixelRatio = Math.max(
1,
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
);
2026-05-10 03:06:54 +07:00
const { width, height } = project.canvas;
const canvas = document.createElement("canvas");
canvas.width = width * pixelRatio;
canvas.height = height * pixelRatio;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cannot create export canvas context");
ctx.scale(pixelRatio, pixelRatio);
for (const layer of project.layers) {
2026-05-21 00:02:12 +07:00
if (layer.visible === false) continue;
2026-05-10 03:06:54 +07:00
await drawLayer(ctx, layer, options.isDark);
}
const dataUrl = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = dataUrl;
link.download = `${project.title || "pien-project"}.png`;
document.body.appendChild(link);
link.click();
link.remove();
}