refboard: add native image copy and export renderer

This commit is contained in:
Hiren Kangad
2026-03-13 15:48:12 +05:30
parent ae9b1ecefe
commit 7b2fcfd6a8
3 changed files with 269 additions and 174 deletions
+10 -43
View File
@@ -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
View File
@@ -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),
}; };
} }
+214
View File
@@ -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;
}