Files
image-studio/platform/browser/exportArtboardPng.ts
syntaxbullet ff762b8f17 feat: add inpaint region functionality and related tools
- Enhanced cursor behavior for new tools: semantic select, mask lasso, and mask rectangle.
- Updated mask edit state to include mask asset ID and kind.
- Implemented inpaint region commands for adding, applying, and removing inpaint regions.
- Introduced new operations for lasso and semantic selection tools.
- Created UI components for candidate review and inpaint region management.
- Added tests for inpaint region commands to ensure functionality.
- Updated various components to support new inpaint features and improve user experience.
2026-07-11 16:41:22 +02:00

241 lines
9.4 KiB
TypeScript

import type { Artboard } from "@core/artboard";
import type { Asset } from "@core/asset";
import type { Rect } from "@core/geometry";
import type { Layer } from "@core/layer";
import type { Size } from "@core/geometry";
import { getLayerMask } from "@core/layer-mask-utils";
import { measureTextLayer } from "@core/text-layer";
export async function downloadArtboardPng(artboard: Artboard, assets: readonly Asset[]) {
const canvas = await renderArtboardCanvas(artboard, assets);
const url = canvas.toDataURL("image/png");
const link = document.createElement("a");
link.href = url;
link.download = `${safeFilename(artboard.name)}.png`;
link.click();
}
export async function renderArtboardCanvas(artboard: Artboard, assets: readonly Asset[]): Promise<HTMLCanvasElement> {
const width = Math.max(1, Math.round(artboard.bounds.w));
const height = Math.max(1, Math.round(artboard.bounds.h));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is not available");
if (artboard.backgroundColor !== "transparent") {
context.fillStyle = artboard.backgroundColor;
context.fillRect(0, 0, width, height);
}
const maskLayerIds = collectMaskLayerIds(artboard.layers);
context.save();
context.translate(-artboard.bounds.x, -artboard.bounds.y);
for (const layer of renderStack(artboard.layers)) await drawLayer(context, layer, artboard.layers, assets, artboard.bounds, { maskLayerIds });
context.restore();
return canvas;
}
async function drawLayer(
context: CanvasRenderingContext2D,
layer: Layer,
layerTree: readonly Layer[],
assets: readonly Asset[],
artboardBounds: Rect,
options: { maskLayerIds: ReadonlySet<string>; ignoreOwnMask?: boolean },
) {
if (!layer.visible || (!options.ignoreOwnMask && options.maskLayerIds.has(layer.id))) return;
const layerMask = getLayerMask(layer);
if (!options.ignoreOwnMask && layerMask?.enabled) {
const maskLayer = findLayer(layerTree, layerMask.maskLayerId);
if (!maskLayer) return;
await drawMaskedLayer(context, layer, maskLayer, layerTree, assets, artboardBounds);
return;
}
context.save();
context.globalAlpha *= layer.opacity;
if (layer.type === "group") {
const groupCanvas = createArtboardCanvas(artboardBounds);
const groupContext = translatedContext(groupCanvas, artboardBounds);
const groupMaskIds = collectMaskLayerIds(layer.children);
for (const child of renderStack(layer.children)) await drawLayer(groupContext, child, layer.children, assets, artboardBounds, { maskLayerIds: groupMaskIds });
groupContext.restore();
context.drawImage(groupCanvas, artboardBounds.x, artboardBounds.y);
context.restore();
return;
}
if (layer.type === "adjustment") {
applyAdjustmentToCanvas(context, layer.adjustment, layer.opacity);
context.restore();
return;
}
if (layer.type === "text") {
drawTextLayer(context, layer);
context.restore();
return;
}
const asset = assets.find((candidate) => candidate.id === layer.assetId);
if (!asset) {
context.restore();
return;
}
const image = await loadImage(asset.source);
const draw = resolveLayerDrawImage(layer, asset.intrinsicSize);
context.drawImage(image, draw.source.x, draw.source.y, draw.source.w, draw.source.h,
draw.destination.x, draw.destination.y, draw.destination.w, draw.destination.h);
context.restore();
}
export function drawTextLayer(context: CanvasRenderingContext2D, layer: Extract<Layer, { type: "text" }>) {
const draw = resolveTextLayerDraw(layer);
const { size } = draw;
context.translate(draw.center.x, draw.center.y);
context.rotate(layer.transform.rotation);
context.scale(layer.transform.scale.x, layer.transform.scale.y);
context.fillStyle = layer.style.color;
context.font = `${layer.style.fontStyle} ${layer.style.fontWeight} ${layer.style.fontSize}px ${JSON.stringify(layer.style.fontFamily)}, Arial, sans-serif`;
context.textAlign = layer.style.alignment;
context.textBaseline = "alphabetic";
const x = draw.textX;
layer.content.split("\n").forEach((line, index) => context.fillText(line, x, (index + 0.82) * layer.style.fontSize * layer.style.lineHeight - size.h / 2));
}
export function resolveTextLayerDraw(layer: Extract<Layer, { type: "text" }>) {
const size = measureTextLayer(layer);
return {
size,
center: { x: layer.transform.position.x + size.w * layer.transform.scale.x / 2, y: layer.transform.position.y + size.h * layer.transform.scale.y / 2 },
textX: (layer.style.alignment === "left" ? 0 : layer.style.alignment === "center" ? size.w / 2 : size.w) - size.w / 2,
};
}
export function resolveLayerDrawImage(layer: Extract<Layer, { type: "image" | "raster" }>, intrinsicSize: Size) {
const source = layer.sourceRect ?? { x: 0, y: 0, ...intrinsicSize };
return {
source,
destination: {
x: layer.transform.position.x + source.x * layer.transform.scale.x,
y: layer.transform.position.y + source.y * layer.transform.scale.y,
w: source.w * layer.transform.scale.x,
h: source.h * layer.transform.scale.y,
},
};
}
export function adjustPixel(rgb: readonly [number, number, number], adjustment: Extract<Layer, { type: "adjustment" }>["adjustment"]): [number, number, number] {
let [red, green, blue] = rgb.map((value) => value / 255) as [number, number, number];
red += adjustment.brightness + adjustment.colorBalance.red;
green += adjustment.brightness + adjustment.colorBalance.green;
blue += adjustment.brightness + adjustment.colorBalance.blue;
const contrast = 1 + adjustment.contrast;
red = (red - 0.5) * contrast + 0.5;
green = (green - 0.5) * contrast + 0.5;
blue = (blue - 0.5) * contrast + 0.5;
const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722;
const saturation = 1 + adjustment.saturation;
return [red, green, blue].map((value) => Math.round(Math.max(0, Math.min(1, luminance + (value - luminance) * saturation)) * 255)) as [number, number, number];
}
function applyAdjustmentToCanvas(context: CanvasRenderingContext2D, adjustment: Extract<Layer, { type: "adjustment" }>["adjustment"], opacity: number) {
if (opacity <= 0) return;
const { width, height } = context.canvas;
const image = context.getImageData(0, 0, width, height);
for (let index = 0; index < image.data.length; index += 4) {
const original: [number, number, number] = [image.data[index]!, image.data[index + 1]!, image.data[index + 2]!];
const adjusted = adjustPixel(original, adjustment);
image.data[index] = Math.round(original[0] + (adjusted[0] - original[0]) * opacity);
image.data[index + 1] = Math.round(original[1] + (adjusted[1] - original[1]) * opacity);
image.data[index + 2] = Math.round(original[2] + (adjusted[2] - original[2]) * opacity);
}
context.putImageData(image, 0, 0);
}
async function drawMaskedLayer(
context: CanvasRenderingContext2D,
layer: Layer,
maskLayer: Layer,
layerTree: readonly Layer[],
assets: readonly Asset[],
artboardBounds: Rect,
) {
const layerCanvas = createArtboardCanvas(artboardBounds);
const layerContext = translatedContext(layerCanvas, artboardBounds);
await drawLayer(layerContext, layer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
layerContext.restore();
const maskCanvas = createArtboardCanvas(artboardBounds);
const maskContext = translatedContext(maskCanvas, artboardBounds);
await drawLayer(maskContext, maskLayer, layerTree, assets, artboardBounds, { maskLayerIds: new Set(), ignoreOwnMask: true });
maskContext.restore();
const compositeContext = layerCanvas.getContext("2d");
if (!compositeContext) return;
compositeContext.globalCompositeOperation = "destination-in";
compositeContext.drawImage(maskCanvas, 0, 0);
compositeContext.globalCompositeOperation = "source-over";
context.drawImage(layerCanvas, artboardBounds.x, artboardBounds.y);
}
function renderStack(layers: readonly Layer[]) {
return [...layers].reverse();
}
function createArtboardCanvas(bounds: Rect) {
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bounds.w));
canvas.height = Math.max(1, Math.round(bounds.h));
return canvas;
}
function translatedContext(canvas: HTMLCanvasElement, bounds: Rect) {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is not available");
context.save();
context.translate(-bounds.x, -bounds.y);
return context;
}
function collectMaskLayerIds(layers: readonly Layer[], ids = new Set<string>()) {
for (const layer of layers) {
const layerMask = getLayerMask(layer);
if (layerMask) ids.add(layerMask.maskLayerId);
if (layer.type === "group") collectMaskLayerIds(layer.children, ids);
}
return ids;
}
function findLayer(layers: readonly Layer[], layerId: string): Layer | undefined {
for (const layer of layers) {
if (layer.id === layerId) return layer;
if (layer.type === "group") {
const child = findLayer(layer.children, layerId);
if (child) return child;
}
}
return undefined;
}
function loadImage(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image for export"));
image.src = source;
});
}
function safeFilename(name: string) {
return name.trim().replace(/[^a-z0-9-_]+/gi, "-").replace(/^-+|-+$/g, "") || "artboard";
}