mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
import type { Layer, Project } from "@pien-studio/types";
|
|
import { renderImageWithFaceBlur } from "./face-blur-renderer";
|
|
|
|
type ExportOptions = {
|
|
isDark: boolean;
|
|
pixelRatio?: number;
|
|
};
|
|
|
|
function clampOpacity(value: number | undefined) {
|
|
if (typeof value !== "number" || Number.isNaN(value)) return 1;
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|
|
|
|
function loadImage(src: string) {
|
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = reject;
|
|
image.src = src;
|
|
});
|
|
}
|
|
|
|
function drawFallbackLayer(
|
|
ctx: CanvasRenderingContext2D,
|
|
layer: Layer,
|
|
isDark: boolean,
|
|
) {
|
|
const text = layer.name ?? layer.type;
|
|
const width = Math.max(80, layer.width ?? 120);
|
|
const height = Math.max(34, layer.height ?? 40);
|
|
const radius = 8;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(radius, 0);
|
|
ctx.lineTo(width - radius, 0);
|
|
ctx.quadraticCurveTo(width, 0, width, radius);
|
|
ctx.lineTo(width, height - radius);
|
|
ctx.quadraticCurveTo(width, height, width - radius, height);
|
|
ctx.lineTo(radius, height);
|
|
ctx.quadraticCurveTo(0, height, 0, height - radius);
|
|
ctx.lineTo(0, radius);
|
|
ctx.quadraticCurveTo(0, 0, radius, 0);
|
|
ctx.closePath();
|
|
|
|
ctx.fillStyle = isDark ? "#2d3036" : "#ffffff";
|
|
ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)";
|
|
ctx.lineWidth = 1;
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430";
|
|
ctx.font =
|
|
"600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(text, width / 2, height / 2);
|
|
}
|
|
|
|
async function drawLayer(
|
|
ctx: CanvasRenderingContext2D,
|
|
layer: Layer,
|
|
isDark: boolean,
|
|
) {
|
|
const width =
|
|
layer.width ??
|
|
(layer.type === "image" ? Math.round(200 * layer.scale) : 120);
|
|
const height =
|
|
layer.height ??
|
|
(layer.type === "image" ? Math.round(150 * layer.scale) : 40);
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = clampOpacity(layer.opacity);
|
|
ctx.translate(layer.x + width / 2, layer.y + height / 2);
|
|
ctx.rotate((layer.rotation * Math.PI) / 180);
|
|
ctx.translate(-width / 2, -height / 2);
|
|
|
|
if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) {
|
|
try {
|
|
const image = await loadImage(layer.sourceUri);
|
|
renderImageWithFaceBlur(ctx, image, layer.faceBlur, width, height);
|
|
} catch {
|
|
drawFallbackLayer(ctx, layer, isDark);
|
|
}
|
|
} else {
|
|
drawFallbackLayer(ctx, layer, isDark);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
export async function exportProjectAsPng(
|
|
project: Project,
|
|
options: ExportOptions,
|
|
) {
|
|
const pixelRatio = Math.max(
|
|
1,
|
|
Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1),
|
|
);
|
|
const { width, height } = project.canvas;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = width * pixelRatio;
|
|
canvas.height = height * pixelRatio;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Cannot create export canvas context");
|
|
|
|
ctx.scale(pixelRatio, pixelRatio);
|
|
|
|
for (const layer of project.layers) {
|
|
await drawLayer(ctx, layer, options.isDark);
|
|
}
|
|
|
|
const dataUrl = canvas.toDataURL("image/png");
|
|
const link = document.createElement("a");
|
|
link.href = dataUrl;
|
|
link.download = `${project.title || "pien-project"}.png`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
}
|