refboard: add native image copy and export renderer
This commit is contained in:
@@ -9,8 +9,12 @@
|
|||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { Application } from 'pixi.js';
|
import type { Application } from 'pixi.js';
|
||||||
import type { SceneManager, SceneItem } from './SceneManager';
|
import type { SceneManager, SceneItem } from './SceneManager';
|
||||||
import { getItemWorldBounds } from './SceneManager';
|
|
||||||
import { uploadImage } from '../api';
|
import { uploadImage } from '../api';
|
||||||
|
import {
|
||||||
|
renderCanvasSnapshot,
|
||||||
|
renderNativeImageSelection,
|
||||||
|
supportsNativeImageExport,
|
||||||
|
} from './snapshotRenderer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write selected items (or full viewport) to system clipboard as PNG.
|
* Write selected items (or full viewport) to system clipboard as PNG.
|
||||||
@@ -24,53 +28,16 @@ export async function writeCanvasToClipboard(
|
|||||||
if (!viewport) throw new Error('Viewport not available');
|
if (!viewport) throw new Error('Viewport not available');
|
||||||
if (!app) throw new Error('Renderer not available');
|
if (!app) throw new Error('Renderer not available');
|
||||||
|
|
||||||
const directCanvas = app.canvas as unknown as HTMLCanvasElement | undefined;
|
|
||||||
const sourceCanvas = directCanvas
|
|
||||||
?? (app.renderer?.extract
|
|
||||||
? (app.renderer.extract.canvas(viewport) as HTMLCanvasElement)
|
|
||||||
: undefined);
|
|
||||||
if (!sourceCanvas || sourceCanvas.width <= 0 || sourceCanvas.height <= 0) {
|
|
||||||
throw new Error('Rendered canvas unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
let outputCanvas: HTMLCanvasElement;
|
let outputCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
if (items && items.length > 0) {
|
if (items && items.length > 0) {
|
||||||
// Compute world-space bounding box, then convert to visible screen-space crop.
|
if (supportsNativeImageExport(items)) {
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
outputCanvas = await renderNativeImageSelection(items, { background: null, paddingPx: 10 });
|
||||||
for (const item of items) {
|
} else {
|
||||||
const { x, y, w, h } = getItemWorldBounds(item);
|
outputCanvas = renderCanvasSnapshot(app, viewport, items, { background: null, paddingPx: 12 });
|
||||||
minX = Math.min(minX, x);
|
|
||||||
minY = Math.min(minY, y);
|
|
||||||
maxX = Math.max(maxX, x + w);
|
|
||||||
maxY = Math.max(maxY, y + h);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenTL = viewport.toScreen(minX, minY);
|
|
||||||
const screenBR = viewport.toScreen(maxX, maxY);
|
|
||||||
const rect = sourceCanvas.getBoundingClientRect();
|
|
||||||
const scaleX = rect.width > 0 ? sourceCanvas.width / rect.width : 1;
|
|
||||||
const scaleY = rect.height > 0 ? sourceCanvas.height / rect.height : 1;
|
|
||||||
const pad = 12;
|
|
||||||
const sx = Math.max(0, Math.floor(Math.min(screenTL.x, screenBR.x) * scaleX) - pad);
|
|
||||||
const sy = Math.max(0, Math.floor(Math.min(screenTL.y, screenBR.y) * scaleY) - pad);
|
|
||||||
const sw = Math.max(1, Math.min(sourceCanvas.width - sx, Math.ceil(Math.abs(screenBR.x - screenTL.x) * scaleX) + pad * 2));
|
|
||||||
const sh = Math.max(1, Math.min(sourceCanvas.height - sy, Math.ceil(Math.abs(screenBR.y - screenTL.y) * scaleY) + pad * 2));
|
|
||||||
|
|
||||||
outputCanvas = document.createElement('canvas');
|
|
||||||
outputCanvas.width = sw;
|
|
||||||
outputCanvas.height = sh;
|
|
||||||
const ctx2d = outputCanvas.getContext('2d')!;
|
|
||||||
ctx2d.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
|
|
||||||
ctx2d.drawImage(sourceCanvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
|
||||||
} else {
|
} else {
|
||||||
// Full current view copy.
|
outputCanvas = renderCanvasSnapshot(app, viewport, undefined, { background: null });
|
||||||
outputCanvas = document.createElement('canvas');
|
|
||||||
outputCanvas.width = sourceCanvas.width;
|
|
||||||
outputCanvas.height = sourceCanvas.height;
|
|
||||||
const ctx2d = outputCanvas.getContext('2d')!;
|
|
||||||
ctx2d.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
|
|
||||||
ctx2d.drawImage(sourceCanvas, 0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
|||||||
+45
-131
@@ -1,137 +1,65 @@
|
|||||||
/**
|
/**
|
||||||
* Export canvas content as a downloadable image file.
|
* Export canvas content as a downloadable image file.
|
||||||
* Reuses the same rendering pipeline as clipboard.ts.
|
*
|
||||||
|
* Image-only selections use the native-resolution renderer based on canonical
|
||||||
|
* image geometry. Mixed selections fall back to the rendered canvas snapshot.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Container, Rectangle } from 'pixi.js';
|
|
||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { Application } from 'pixi.js';
|
import type { Application } from 'pixi.js';
|
||||||
import type { SceneItem } from './SceneManager';
|
import type { SceneItem } from './SceneManager';
|
||||||
import { getItemWorldBounds } from './SceneManager';
|
import { getItemWorldBounds } from './SceneManager';
|
||||||
import { getImageVisibleLocalRect } from './imageTransforms';
|
import {
|
||||||
import type { ImageObject } from './scene-format';
|
getNativeImageExportDimensions,
|
||||||
|
renderCanvasSnapshot,
|
||||||
|
renderNativeImageSelection,
|
||||||
|
supportsNativeImageExport,
|
||||||
|
} from './snapshotRenderer';
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'png' | 'jpeg' | 'webp';
|
format: 'png' | 'jpeg' | 'webp';
|
||||||
quality: number; // 0-1, only used for jpeg/webp
|
quality: number;
|
||||||
scale: number; // 1 = native, 0.5 = half, 2 = double
|
scale: number;
|
||||||
background: string; // hex color
|
background: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render selected items (or full board content) to a canvas at the given scale.
|
|
||||||
* Returns an HTMLCanvasElement ready for blob conversion.
|
|
||||||
*/
|
|
||||||
function renderToCanvas(
|
|
||||||
app: Application,
|
|
||||||
viewport: Viewport,
|
|
||||||
items: SceneItem[],
|
|
||||||
scale: number,
|
|
||||||
background: string,
|
|
||||||
): HTMLCanvasElement {
|
|
||||||
// Compute world-space bounding box
|
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
||||||
for (const item of items) {
|
|
||||||
const { x, y, w, h } = getItemWorldBounds(item);
|
|
||||||
minX = Math.min(minX, x);
|
|
||||||
minY = Math.min(minY, y);
|
|
||||||
maxX = Math.max(maxX, x + w);
|
|
||||||
maxY = Math.max(maxY, y + h);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pad = 10;
|
|
||||||
const totalW = Math.ceil(maxX - minX) + pad * 2;
|
|
||||||
const totalH = Math.ceil(maxY - minY) + pad * 2;
|
|
||||||
|
|
||||||
// Reparent items into temp container
|
|
||||||
const tempContainer = new Container();
|
|
||||||
const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: number; angle: number; globalX: number; globalY: number }>();
|
|
||||||
for (const item of items) {
|
|
||||||
const obj = item.displayObject;
|
|
||||||
const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position;
|
|
||||||
saved.set(item.id, {
|
|
||||||
parent: obj.parent as Container,
|
|
||||||
x: obj.x, y: obj.y,
|
|
||||||
sx: obj.scale.x, sy: obj.scale.y,
|
|
||||||
angle: obj.angle,
|
|
||||||
globalX: globalOrigin.x,
|
|
||||||
globalY: globalOrigin.y,
|
|
||||||
});
|
|
||||||
obj.parent?.removeChild(obj);
|
|
||||||
const s = saved.get(item.id)!;
|
|
||||||
obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad);
|
|
||||||
obj.scale.set(s.sx, s.sy);
|
|
||||||
obj.angle = s.angle;
|
|
||||||
tempContainer.addChild(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute resolution: use native texture ratio, then apply user scale
|
|
||||||
let maxRatio = 1;
|
|
||||||
for (const item of items) {
|
|
||||||
const tex = (item.displayObject as any)?.texture;
|
|
||||||
const displayW = item.type === 'image'
|
|
||||||
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
|
||||||
: item.data.w * Math.abs(item.data.sx);
|
|
||||||
if (tex && tex.width > 1 && displayW > 1) {
|
|
||||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const resolution = Math.min(maxRatio * scale, 8); // cap at 8x to avoid GPU limits
|
|
||||||
|
|
||||||
let texture;
|
|
||||||
let extractedCanvas: HTMLCanvasElement;
|
|
||||||
try {
|
|
||||||
texture = app.renderer.generateTexture({
|
|
||||||
target: tempContainer,
|
|
||||||
resolution,
|
|
||||||
frame: new Rectangle(0, 0, totalW, totalH),
|
|
||||||
});
|
|
||||||
extractedCanvas = app.renderer.extract.canvas(texture) as HTMLCanvasElement;
|
|
||||||
} finally {
|
|
||||||
for (const item of items) {
|
|
||||||
const s = saved.get(item.id)!;
|
|
||||||
tempContainer.removeChild(item.displayObject);
|
|
||||||
s.parent.addChild(item.displayObject);
|
|
||||||
item.displayObject.position.set(s.x, s.y);
|
|
||||||
item.displayObject.scale.set(s.sx, s.sy);
|
|
||||||
item.displayObject.angle = s.angle;
|
|
||||||
}
|
|
||||||
tempContainer.destroy();
|
|
||||||
texture?.destroy(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add background
|
|
||||||
const outputCanvas = document.createElement('canvas');
|
|
||||||
outputCanvas.width = extractedCanvas.width;
|
|
||||||
outputCanvas.height = extractedCanvas.height;
|
|
||||||
const ctx = outputCanvas.getContext('2d')!;
|
|
||||||
ctx.fillStyle = background;
|
|
||||||
ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
|
||||||
ctx.drawImage(extractedCanvas, 0, 0);
|
|
||||||
|
|
||||||
return outputCanvas;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIME_MAP = {
|
const MIME_MAP = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
async function renderToCanvas(
|
||||||
* Export items as a downloadable image.
|
app: Application,
|
||||||
*/
|
viewport: Viewport,
|
||||||
|
items: SceneItem[],
|
||||||
|
options: ExportOptions,
|
||||||
|
): Promise<HTMLCanvasElement> {
|
||||||
|
if (supportsNativeImageExport(items)) {
|
||||||
|
return renderNativeImageSelection(items, {
|
||||||
|
scale: options.scale,
|
||||||
|
background: options.background,
|
||||||
|
paddingPx: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderCanvasSnapshot(app, viewport, items, {
|
||||||
|
background: options.background,
|
||||||
|
paddingPx: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportAsImage(
|
export async function exportAsImage(
|
||||||
app: Application | null,
|
app: Application | null,
|
||||||
viewport: Viewport | null,
|
viewport: Viewport | null,
|
||||||
items: SceneItem[],
|
items: SceneItem[],
|
||||||
options: ExportOptions,
|
options: ExportOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!app?.renderer?.extract || !viewport) throw new Error('Renderer not available');
|
if (!app || !viewport) throw new Error('Renderer not available');
|
||||||
if (items.length === 0) throw new Error('No items to export');
|
if (items.length === 0) throw new Error('No items to export');
|
||||||
|
|
||||||
const canvas = renderToCanvas(app, viewport, items, options.scale, options.background);
|
const canvas = await renderToCanvas(app, viewport, items, options);
|
||||||
const mimeType = MIME_MAP[options.format];
|
const mimeType = MIME_MAP[options.format];
|
||||||
const quality = options.format === 'png' ? undefined : options.quality;
|
const quality = options.format === 'png' ? undefined : options.quality;
|
||||||
|
|
||||||
@@ -143,7 +71,6 @@ export async function exportAsImage(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger browser download
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -154,17 +81,20 @@ export async function exportAsImage(
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the pixel dimensions of the export at a given scale,
|
|
||||||
* so the dialog can show a preview of the output size.
|
|
||||||
*/
|
|
||||||
export function getExportDimensions(
|
export function getExportDimensions(
|
||||||
items: SceneItem[],
|
items: SceneItem[],
|
||||||
scale: number,
|
scale: number,
|
||||||
): { width: number; height: number } {
|
): { width: number; height: number } {
|
||||||
if (items.length === 0) return { width: 0, height: 0 };
|
if (items.length === 0) return { width: 0, height: 0 };
|
||||||
|
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
if (supportsNativeImageExport(items)) {
|
||||||
|
return getNativeImageExportDimensions(items, scale, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const { x, y, w, h } = getItemWorldBounds(item);
|
const { x, y, w, h } = getItemWorldBounds(item);
|
||||||
minX = Math.min(minX, x);
|
minX = Math.min(minX, x);
|
||||||
@@ -173,25 +103,9 @@ export function getExportDimensions(
|
|||||||
maxY = Math.max(maxY, y + h);
|
maxY = Math.max(maxY, y + h);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pad = 10;
|
const pad = 12;
|
||||||
const w = Math.ceil(maxX - minX) + pad * 2;
|
|
||||||
const h = Math.ceil(maxY - minY) + pad * 2;
|
|
||||||
|
|
||||||
// Estimate native resolution ratio
|
|
||||||
let maxRatio = 1;
|
|
||||||
for (const item of items) {
|
|
||||||
const tex = (item.displayObject as any)?.texture;
|
|
||||||
const displayW = item.type === 'image'
|
|
||||||
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
|
||||||
: item.data.w * Math.abs(item.data.sx);
|
|
||||||
if (tex && tex.width > 1 && displayW > 1) {
|
|
||||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const resolution = Math.min(maxRatio * scale, 8);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: Math.round(w * resolution),
|
width: Math.max(1, Math.round(maxX - minX) + pad * 2),
|
||||||
height: Math.round(h * resolution),
|
height: Math.max(1, Math.round(maxY - minY) + pad * 2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import type { Application } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
|
import type { SceneItem } from './SceneManager';
|
||||||
|
import { getItemWorldBounds } from './SceneManager';
|
||||||
|
import {
|
||||||
|
getImageDisplayTransform,
|
||||||
|
getImageSourceRect,
|
||||||
|
} from './imageTransforms';
|
||||||
|
import type { ImageObject } from './scene-format';
|
||||||
|
|
||||||
|
const imageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||||
|
|
||||||
|
function assetUrlForExport(assetKey: string): string {
|
||||||
|
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
|
||||||
|
return assetKey;
|
||||||
|
}
|
||||||
|
return `/api/images/${assetKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImageForExport(assetKey: string): Promise<HTMLImageElement> {
|
||||||
|
const existing = imageCache.get(assetKey);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.decoding = 'async';
|
||||||
|
img.onload = async () => {
|
||||||
|
try {
|
||||||
|
if ('decode' in img) await img.decode();
|
||||||
|
} catch {
|
||||||
|
// ignore decode timing issues; onload already fired
|
||||||
|
}
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error(`Failed to load image asset: ${assetKey}`));
|
||||||
|
img.src = assetUrlForExport(assetKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
imageCache.set(assetKey, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsNativeImageExport(items: SceneItem[]): boolean {
|
||||||
|
return items.length > 0 && items.every((item) => item.type === 'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNativePixelsPerWorld(items: SceneItem[], scale = 1): number {
|
||||||
|
let pixelsPerWorld = 1;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.type !== 'image') continue;
|
||||||
|
const data = item.data as ImageObject;
|
||||||
|
const pxPerWorldX = data.sx !== 0 ? 1 / Math.abs(data.sx) : 1;
|
||||||
|
const pxPerWorldY = data.sy !== 0 ? 1 / Math.abs(data.sy) : 1;
|
||||||
|
pixelsPerWorld = Math.max(pixelsPerWorld, pxPerWorldX, pxPerWorldY);
|
||||||
|
}
|
||||||
|
return Math.min(pixelsPerWorld * scale, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderNativeImageSelection(
|
||||||
|
items: SceneItem[],
|
||||||
|
options?: { scale?: number; background?: string | null; paddingPx?: number },
|
||||||
|
): Promise<HTMLCanvasElement> {
|
||||||
|
if (!supportsNativeImageExport(items)) {
|
||||||
|
throw new Error('Native export currently supports image-only selections');
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const item of items) {
|
||||||
|
const bounds = getItemWorldBounds(item);
|
||||||
|
minX = Math.min(minX, bounds.x);
|
||||||
|
minY = Math.min(minY, bounds.y);
|
||||||
|
maxX = Math.max(maxX, bounds.x + bounds.w);
|
||||||
|
maxY = Math.max(maxY, bounds.y + bounds.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixelsPerWorld = getNativePixelsPerWorld(items, options?.scale ?? 1);
|
||||||
|
const paddingPx = options?.paddingPx ?? 10;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.ceil((maxX - minX) * pixelsPerWorld) + paddingPx * 2);
|
||||||
|
canvas.height = Math.max(1, Math.ceil((maxY - minY) * pixelsPerWorld) + paddingPx * 2);
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('2D context unavailable');
|
||||||
|
|
||||||
|
if (options?.background) {
|
||||||
|
ctx.fillStyle = options.background;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
} else {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedItems = [...items].sort((a, b) => a.data.z - b.data.z);
|
||||||
|
|
||||||
|
for (const item of orderedItems) {
|
||||||
|
const data = item.data as ImageObject;
|
||||||
|
const img = await loadImageForExport(data.asset);
|
||||||
|
const src = getImageSourceRect(data);
|
||||||
|
const t = getImageDisplayTransform(data);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(
|
||||||
|
Math.round((t.x - minX) * pixelsPerWorld) + paddingPx,
|
||||||
|
Math.round((t.y - minY) * pixelsPerWorld) + paddingPx,
|
||||||
|
);
|
||||||
|
ctx.rotate((t.angle * Math.PI) / 180);
|
||||||
|
ctx.scale(t.scaleX * pixelsPerWorld, t.scaleY * pixelsPerWorld);
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
src.x,
|
||||||
|
src.y,
|
||||||
|
src.w,
|
||||||
|
src.h,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
src.w,
|
||||||
|
src.h,
|
||||||
|
);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNativeImageExportDimensions(
|
||||||
|
items: SceneItem[],
|
||||||
|
scale = 1,
|
||||||
|
paddingPx = 10,
|
||||||
|
): { width: number; height: number } {
|
||||||
|
if (!supportsNativeImageExport(items)) return { width: 0, height: 0 };
|
||||||
|
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const item of items) {
|
||||||
|
const bounds = getItemWorldBounds(item);
|
||||||
|
minX = Math.min(minX, bounds.x);
|
||||||
|
minY = Math.min(minY, bounds.y);
|
||||||
|
maxX = Math.max(maxX, bounds.x + bounds.w);
|
||||||
|
maxY = Math.max(maxY, bounds.y + bounds.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pixelsPerWorld = getNativePixelsPerWorld(items, scale);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.ceil((maxX - minX) * pixelsPerWorld) + paddingPx * 2),
|
||||||
|
height: Math.max(1, Math.ceil((maxY - minY) * pixelsPerWorld) + paddingPx * 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderCanvasSnapshot(
|
||||||
|
app: Application,
|
||||||
|
viewport: Viewport,
|
||||||
|
items?: SceneItem[],
|
||||||
|
options?: { background?: string | null; paddingPx?: number },
|
||||||
|
): HTMLCanvasElement {
|
||||||
|
const directCanvas = app.canvas as unknown as HTMLCanvasElement | undefined;
|
||||||
|
const sourceCanvas = directCanvas
|
||||||
|
?? (app.renderer?.extract
|
||||||
|
? (app.renderer.extract.canvas(viewport) as HTMLCanvasElement)
|
||||||
|
: undefined);
|
||||||
|
if (!sourceCanvas || sourceCanvas.width <= 0 || sourceCanvas.height <= 0) {
|
||||||
|
throw new Error('Rendered canvas unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let sw = sourceCanvas.width;
|
||||||
|
let sh = sourceCanvas.height;
|
||||||
|
|
||||||
|
if (items && items.length > 0) {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const item of items) {
|
||||||
|
const bounds = getItemWorldBounds(item);
|
||||||
|
minX = Math.min(minX, bounds.x);
|
||||||
|
minY = Math.min(minY, bounds.y);
|
||||||
|
maxX = Math.max(maxX, bounds.x + bounds.w);
|
||||||
|
maxY = Math.max(maxY, bounds.y + bounds.h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenTL = viewport.toScreen(minX, minY);
|
||||||
|
const screenBR = viewport.toScreen(maxX, maxY);
|
||||||
|
const rect = sourceCanvas.getBoundingClientRect();
|
||||||
|
const scaleX = rect.width > 0 ? sourceCanvas.width / rect.width : 1;
|
||||||
|
const scaleY = rect.height > 0 ? sourceCanvas.height / rect.height : 1;
|
||||||
|
const pad = options?.paddingPx ?? 12;
|
||||||
|
|
||||||
|
sx = Math.max(0, Math.floor(Math.min(screenTL.x, screenBR.x) * scaleX) - pad);
|
||||||
|
sy = Math.max(0, Math.floor(Math.min(screenTL.y, screenBR.y) * scaleY) - pad);
|
||||||
|
sw = Math.max(1, Math.min(sourceCanvas.width - sx, Math.ceil(Math.abs(screenBR.x - screenTL.x) * scaleX) + pad * 2));
|
||||||
|
sh = Math.max(1, Math.min(sourceCanvas.height - sy, Math.ceil(Math.abs(screenBR.y - screenTL.y) * scaleY) + pad * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputCanvas = document.createElement('canvas');
|
||||||
|
outputCanvas.width = sw;
|
||||||
|
outputCanvas.height = sh;
|
||||||
|
const ctx = outputCanvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('2D context unavailable');
|
||||||
|
|
||||||
|
if (options?.background) {
|
||||||
|
ctx.fillStyle = options.background;
|
||||||
|
ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||||
|
} else {
|
||||||
|
ctx.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(sourceCanvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||||
|
return outputCanvas;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user