From 7b2fcfd6a8e91bc5c34e0ad7241c0fb92e5fa8fe Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Fri, 13 Mar 2026 15:48:12 +0530 Subject: [PATCH] refboard: add native image copy and export renderer --- frontend/src/canvas/clipboard.ts | 53 ++---- frontend/src/canvas/export.ts | 176 +++++-------------- frontend/src/canvas/snapshotRenderer.ts | 214 ++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 174 deletions(-) create mode 100644 frontend/src/canvas/snapshotRenderer.ts diff --git a/frontend/src/canvas/clipboard.ts b/frontend/src/canvas/clipboard.ts index c97fb2d..913380b 100644 --- a/frontend/src/canvas/clipboard.ts +++ b/frontend/src/canvas/clipboard.ts @@ -9,8 +9,12 @@ import type { Viewport } from 'pixi-viewport'; import type { Application } from 'pixi.js'; import type { SceneManager, SceneItem } from './SceneManager'; -import { getItemWorldBounds } from './SceneManager'; import { uploadImage } from '../api'; +import { + renderCanvasSnapshot, + renderNativeImageSelection, + supportsNativeImageExport, +} from './snapshotRenderer'; /** * 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 (!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; if (items && items.length > 0) { - // Compute world-space bounding box, then convert to visible screen-space crop. - 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); + if (supportsNativeImageExport(items)) { + outputCanvas = await renderNativeImageSelection(items, { background: null, paddingPx: 10 }); + } else { + outputCanvas = renderCanvasSnapshot(app, viewport, items, { background: null, paddingPx: 12 }); } - - 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 { - // Full current view copy. - 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); + outputCanvas = renderCanvasSnapshot(app, viewport, undefined, { background: null }); } const blob = await new Promise((resolve, reject) => { diff --git a/frontend/src/canvas/export.ts b/frontend/src/canvas/export.ts index 650f3e5..459bed3 100644 --- a/frontend/src/canvas/export.ts +++ b/frontend/src/canvas/export.ts @@ -1,137 +1,65 @@ /** * 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 { Application } from 'pixi.js'; import type { SceneItem } from './SceneManager'; import { getItemWorldBounds } from './SceneManager'; -import { getImageVisibleLocalRect } from './imageTransforms'; -import type { ImageObject } from './scene-format'; +import { + getNativeImageExportDimensions, + renderCanvasSnapshot, + renderNativeImageSelection, + supportsNativeImageExport, +} from './snapshotRenderer'; export interface ExportOptions { format: 'png' | 'jpeg' | 'webp'; - quality: number; // 0-1, only used for jpeg/webp - scale: number; // 1 = native, 0.5 = half, 2 = double - background: string; // hex color + quality: number; + scale: number; + background: 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(); - 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 = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', } as const; -/** - * Export items as a downloadable image. - */ +async function renderToCanvas( + app: Application, + viewport: Viewport, + items: SceneItem[], + options: ExportOptions, +): Promise { + 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( app: Application | null, viewport: Viewport | null, items: SceneItem[], options: ExportOptions, ): Promise { - 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'); - 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 quality = options.format === 'png' ? undefined : options.quality; @@ -143,7 +71,6 @@ export async function exportAsImage( ); }); - // Trigger browser download const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -154,17 +81,20 @@ export async function exportAsImage( 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( items: SceneItem[], scale: number, ): { width: number; height: number } { 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) { const { x, y, w, h } = getItemWorldBounds(item); minX = Math.min(minX, x); @@ -173,25 +103,9 @@ export function getExportDimensions( maxY = Math.max(maxY, y + h); } - const pad = 10; - 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); - + const pad = 12; return { - width: Math.round(w * resolution), - height: Math.round(h * resolution), + width: Math.max(1, Math.round(maxX - minX) + pad * 2), + height: Math.max(1, Math.round(maxY - minY) + pad * 2), }; } diff --git a/frontend/src/canvas/snapshotRenderer.ts b/frontend/src/canvas/snapshotRenderer.ts new file mode 100644 index 0000000..f5c63b9 --- /dev/null +++ b/frontend/src/canvas/snapshotRenderer.ts @@ -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>(); + +function assetUrlForExport(assetKey: string): string { + if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) { + return assetKey; + } + return `/api/images/${assetKey}`; +} + +async function loadImageForExport(assetKey: string): Promise { + const existing = imageCache.get(assetKey); + if (existing) return existing; + + const promise = new Promise((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 { + 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; +}