From 05118330d69698a95f5a9402163c825678d5aed7 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Fri, 13 Mar 2026 16:59:11 +0530 Subject: [PATCH] feat: unified composition renderer for clipboard/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the split snapshot/native renderer paths with a single composition pipeline (compositionRenderer.ts) that: - Loads actual source images and uses naturalWidth/Height for correct full-resolution sampling (fixes top-left-corner-only bug caused by data.w/h being capped to 600px display dimensions) - Routes image-only selections through native Canvas 2D composition - Falls back to viewport snapshot for mixed/unsupported selections with explicit warnings instead of silent degradation - Resolves group children via itemResolver for proper group export - Rejects group children from native composition (local coords incompatible with world-space drawing) - Adds canvas size safety limits with auto-downscale - Guards VideoSprite._drawFrame against null texture source race condition during zoom-triggered culling New files: - compositionRenderer.ts — unified composition module - compositionRenderer.test.ts — 16 tests for entry flattening, bounds, dimensions, group handling Modified: - clipboard.ts — uses composeSelection() instead of direct renderers - export.ts — uses composeSelection() + getCompositionDimensions() - Editor.tsx, useShortcutHandler.ts — pass scene for group resolution - VideoSprite.ts — null guard on texture source in frame loop --- frontend/src/canvas/clipboard.ts | 37 +- .../src/canvas/compositionRenderer.test.ts | 246 +++++++++ frontend/src/canvas/compositionRenderer.ts | 499 ++++++++++++++++++ frontend/src/canvas/export.ts | 83 +-- frontend/src/canvas/sprites/VideoSprite.ts | 3 + frontend/src/hooks/useShortcutHandler.ts | 3 +- frontend/src/pages/Editor.tsx | 6 +- 7 files changed, 805 insertions(+), 72 deletions(-) create mode 100644 frontend/src/canvas/compositionRenderer.test.ts create mode 100644 frontend/src/canvas/compositionRenderer.ts diff --git a/frontend/src/canvas/clipboard.ts b/frontend/src/canvas/clipboard.ts index 913380b..a9aee54 100644 --- a/frontend/src/canvas/clipboard.ts +++ b/frontend/src/canvas/clipboard.ts @@ -1,42 +1,49 @@ /** * Clipboard module — copy canvas to system clipboard and paste images from it. * - * Copy approach: snapshot the already-rendered canvas, then crop the visible - * selection region in screen space. This preserves crop/flip/rotation/group - * transforms exactly as the user sees them. + * Uses the unified composition renderer for all copy operations. Image-only + * selections produce native-resolution output; mixed selections fall back to + * the viewport snapshot with an explicit warning. */ import type { Viewport } from 'pixi-viewport'; import type { Application } from 'pixi.js'; import type { SceneManager, SceneItem } from './SceneManager'; import { uploadImage } from '../api'; -import { - renderCanvasSnapshot, - renderNativeImageSelection, - supportsNativeImageExport, -} from './snapshotRenderer'; +import { composeSelection } from './compositionRenderer'; +import { renderCanvasSnapshot } from './snapshotRenderer'; /** * Write selected items (or full viewport) to system clipboard as PNG. * Selected items are rendered at their native size, not affected by zoom. + * + * Returns any warnings from the composition pipeline (e.g. fallback used). */ export async function writeCanvasToClipboard( app: Application | null, viewport: Viewport | null, items?: SceneItem[], -): Promise { + scene?: SceneManager, +): Promise { if (!viewport) throw new Error('Viewport not available'); if (!app) throw new Error('Renderer not available'); let outputCanvas: HTMLCanvasElement; + let warnings: string[] = []; + const resolver = scene ? (id: string) => scene.getById(id) : undefined; if (items && items.length > 0) { - if (supportsNativeImageExport(items)) { - outputCanvas = await renderNativeImageSelection(items, { background: null, paddingPx: 10 }); - } else { - outputCanvas = renderCanvasSnapshot(app, viewport, items, { background: null, paddingPx: 12 }); - } + const result = await composeSelection(items, { + mode: 'clipboard', + scale: 1, + background: null, + paddingPx: 10, + fallbackToSnapshot: true, + }, { app, viewport }, resolver); + outputCanvas = result.canvas; + warnings = result.warnings; } else { + // Full viewport capture — always snapshot outputCanvas = renderCanvasSnapshot(app, viewport, undefined, { background: null }); } @@ -50,6 +57,8 @@ export async function writeCanvasToClipboard( await navigator.clipboard.write([ new ClipboardItem({ 'image/png': blob }), ]); + + return warnings; } /** diff --git a/frontend/src/canvas/compositionRenderer.test.ts b/frontend/src/canvas/compositionRenderer.test.ts new file mode 100644 index 0000000..e66e8c9 --- /dev/null +++ b/frontend/src/canvas/compositionRenderer.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect } from 'vitest'; +import type { SceneItem } from './SceneManager'; +import type { ImageObject, AnySceneObject } from './scene-format'; +import { flattenToExportEntries, getCompositionDimensions } from './compositionRenderer'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeImageItem(overrides: Partial = {}): SceneItem { + const data: ImageObject = { + id: overrides.id ?? 'img-1', + type: 'image', + x: overrides.x ?? 0, + y: overrides.y ?? 0, + w: overrides.w ?? 100, + h: overrides.h ?? 80, + sx: overrides.sx ?? 1, + sy: overrides.sy ?? 1, + angle: overrides.angle ?? 0, + z: overrides.z ?? 0, + opacity: overrides.opacity ?? 1, + locked: false, + name: '', + visible: true, + asset: overrides.asset ?? 'test.png', + filters: [], + flipX: overrides.flipX, + flipY: overrides.flipY, + crop: overrides.crop, + }; + return { + id: data.id, + type: 'image', + displayObject: {} as any, + data, + }; +} + +function makeTextItem(overrides: Partial = {}): SceneItem { + const data = { + id: overrides.id ?? 'txt-1', + type: 'text' as const, + x: overrides.x ?? 0, + y: overrides.y ?? 0, + w: overrides.w ?? 200, + h: overrides.h ?? 50, + sx: overrides.sx ?? 1, + sy: overrides.sy ?? 1, + angle: overrides.angle ?? 0, + z: overrides.z ?? 1, + opacity: 1, + locked: false, + name: '', + visible: true, + text: 'hello', + fontSize: 24, + fill: '#000', + fontFamily: 'sans-serif', + }; + return { + id: data.id, + type: 'text', + displayObject: {} as any, + data: data as any, + }; +} + +function makeGroupItem(childIds: string[]): SceneItem { + const data = { + id: 'grp-1', + type: 'group' as const, + x: 0, + y: 0, + w: 300, + h: 200, + sx: 1, + sy: 1, + angle: 0, + z: 0, + opacity: 1, + locked: false, + name: '', + visible: true, + children: childIds, + }; + return { + id: data.id, + type: 'group', + displayObject: {} as any, + data: data as any, + }; +} + +// --------------------------------------------------------------------------- +// flattenToExportEntries +// --------------------------------------------------------------------------- + +describe('flattenToExportEntries', () => { + it('converts image items to export entries with world bounds', () => { + const items = [ + makeImageItem({ id: 'a', x: 10, y: 20, w: 100, h: 80, z: 0 }), + makeImageItem({ id: 'b', x: 200, y: 50, w: 150, h: 100, z: 1 }), + ]; + const entries = flattenToExportEntries(items); + expect(entries).toHaveLength(2); + expect(entries[0].id).toBe('a'); + expect(entries[1].id).toBe('b'); + // z-sorted + expect(entries[0].z).toBeLessThan(entries[1].z); + }); + + it('sorts entries by z-order', () => { + const items = [ + makeImageItem({ id: 'b', z: 5 }), + makeImageItem({ id: 'a', z: 1 }), + makeImageItem({ id: 'c', z: 3 }), + ]; + const entries = flattenToExportEntries(items); + expect(entries.map((e) => e.id)).toEqual(['a', 'c', 'b']); + }); + + it('skips group items — includes children already in selection', () => { + const items = [ + makeGroupItem(['img-1']), + makeImageItem({ id: 'img-1', z: 1 }), + ]; + const entries = flattenToExportEntries(items); + expect(entries).toHaveLength(1); + expect(entries[0].id).toBe('img-1'); + // Children already in selection are NOT marked as group children + expect(entries[0].isGroupChild).toBe(false); + }); + + it('resolves group children via itemResolver and marks them as group children', () => { + const child = makeImageItem({ id: 'child-1', z: 2 }); + const items = [makeGroupItem(['child-1'])]; + const resolver = (id: string) => id === 'child-1' ? child : undefined; + const entries = flattenToExportEntries(items, resolver); + expect(entries).toHaveLength(1); + expect(entries[0].id).toBe('child-1'); + expect(entries[0].isGroupChild).toBe(true); + }); + + it('marks non-group items as isGroupChild=false', () => { + const items = [makeImageItem({ id: 'standalone' })]; + const entries = flattenToExportEntries(items); + expect(entries[0].isGroupChild).toBe(false); + }); + + it('computes correct world bounds for cropped image', () => { + const items = [ + makeImageItem({ + x: 10, + y: 20, + w: 200, + h: 100, + sx: 0.5, + sy: 0.5, + crop: { x: 0.25, y: 0.25, w: 0.5, h: 0.5 }, + }), + ]; + const entries = flattenToExportEntries(items); + expect(entries).toHaveLength(1); + // Cropped visible rect: 100x50 source pixels, scaled by 0.5 = 50x25 world + expect(entries[0].worldBounds.w).toBeCloseTo(50, 1); + expect(entries[0].worldBounds.h).toBeCloseTo(25, 1); + }); + + it('computes correct world bounds for flipped image', () => { + const items = [ + makeImageItem({ + x: 10, + y: 20, + w: 100, + h: 80, + sx: 1, + sy: 1, + flipX: true, + }), + ]; + const entries = flattenToExportEntries(items); + // Flipped image occupies same world-space bounds + expect(entries[0].worldBounds.x).toBeCloseTo(10, 1); + expect(entries[0].worldBounds.y).toBeCloseTo(20, 1); + expect(entries[0].worldBounds.w).toBeCloseTo(100, 1); + expect(entries[0].worldBounds.h).toBeCloseTo(80, 1); + }); +}); + +// --------------------------------------------------------------------------- +// getCompositionDimensions +// --------------------------------------------------------------------------- + +describe('getCompositionDimensions', () => { + it('returns zero for empty selection', () => { + const dims = getCompositionDimensions([], 1, 10); + expect(dims.width).toBe(0); + expect(dims.height).toBe(0); + }); + + it('returns native=true for image-only selection', () => { + const items = [makeImageItem({ w: 400, h: 300, sx: 0.5, sy: 0.5 })]; + const dims = getCompositionDimensions(items, 1, 10); + expect(dims.native).toBe(true); + // At scale 1, ppw = 1/0.5 = 2 + // World size = 200x150, export pixels = 400x300 + 20 padding = 420x320 + expect(dims.width).toBeGreaterThan(400); + expect(dims.height).toBeGreaterThan(300); + }); + + it('returns native=false for mixed selection', () => { + const items = [ + makeImageItem({ z: 0 }), + makeTextItem({ z: 1 }), + ]; + const dims = getCompositionDimensions(items, 1, 10); + expect(dims.native).toBe(false); + }); + + it('scale=1 and scale=2 produce different dimensions for native images', () => { + const items = [makeImageItem({ w: 400, h: 300, sx: 1, sy: 1 })]; + const dims1 = getCompositionDimensions(items, 1, 10); + const dims2 = getCompositionDimensions(items, 2, 10); + expect(dims2.width).toBeGreaterThan(dims1.width); + expect(dims2.height).toBeGreaterThan(dims1.height); + }); + + it('returns native=false for group children (local coords not drawable natively)', () => { + const child = makeImageItem({ id: 'child-1', z: 1 }); + const items = [makeGroupItem(['child-1'])]; + const resolver = (id: string) => id === 'child-1' ? child : undefined; + const dims = getCompositionDimensions(items, 1, 10, resolver); + expect(dims.native).toBe(false); + }); + + it('dimensions are zoom-independent for native images', () => { + // Two identical items — same result regardless of "zoom" + // (composition renderer ignores viewport zoom entirely) + const items = [makeImageItem({ w: 200, h: 150, sx: 0.5, sy: 0.5 })]; + const dims1 = getCompositionDimensions(items, 1, 10); + const dims2 = getCompositionDimensions(items, 1, 10); + expect(dims1.width).toBe(dims2.width); + expect(dims1.height).toBe(dims2.height); + }); +}); diff --git a/frontend/src/canvas/compositionRenderer.ts b/frontend/src/canvas/compositionRenderer.ts new file mode 100644 index 0000000..8312cb2 --- /dev/null +++ b/frontend/src/canvas/compositionRenderer.ts @@ -0,0 +1,499 @@ +/** + * Composition Renderer — unified data-driven export/clipboard pipeline. + * + * Renders scene items from canonical scene data into an offscreen canvas, + * independent of live PixiJS display state or viewport zoom. Image-only + * selections produce native-resolution output; mixed selections fall back + * to the snapshot renderer with an explicit warning. + */ + +import type { Application } from 'pixi.js'; +import type { Viewport } from 'pixi-viewport'; +import type { SceneItem } from './SceneManager'; +import { getItemWorldBounds } from './SceneManager'; +import type { AnySceneObject, GroupObject, ImageObject } from './scene-format'; +import { + getImageDisplayTransform, + getImageSourceRect, +} from './imageTransforms'; +import { renderCanvasSnapshot } from './snapshotRenderer'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type CompositionMode = 'clipboard' | 'export'; + +export interface CompositionOptions { + mode: CompositionMode; + scale: number; + background: string | null; + paddingPx: number; + fallbackToSnapshot: boolean; +} + +export interface CompositionResult { + canvas: HTMLCanvasElement; + native: boolean; + warnings: string[]; +} + +export interface ExportEntry { + id: string; + type: AnySceneObject['type']; + z: number; + worldBounds: { x: number; y: number; w: number; h: number }; + data: AnySceneObject; + /** True if this entry is a child of a group (data stores local coords). */ + isGroupChild: boolean; +} + +/** Per-type drawing interface for the composition pipeline. */ +export interface ItemComposer { + supportsNative(item: ExportEntry): boolean; + draw( + ctx: CanvasRenderingContext2D, + item: ExportEntry, + env: DrawEnv, + ): Promise; +} + +export interface DrawEnv { + pixelsPerWorld: number; + offsetPxX: number; + offsetPxY: number; + /** Pre-loaded images keyed by entry ID. */ + imageMap: Map; +} + +// --------------------------------------------------------------------------- +// Canvas safety limits +// --------------------------------------------------------------------------- + +const MAX_EXPORT_WIDTH = 16384; +const MAX_EXPORT_HEIGHT = 16384; +const MAX_TOTAL_PIXELS = 16384 * 16384; // ~268 million — well within modern browser limits + +// --------------------------------------------------------------------------- +// Image loading cache +// --------------------------------------------------------------------------- + +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 entry flattening +// --------------------------------------------------------------------------- + +/** + * Convert selected SceneItems into flat ExportEntry list with final + * world-space transforms. Groups are resolved: their children are included + * individually at their computed world positions. + * + * @param itemResolver - optional function to look up SceneItems by ID + * (needed to resolve group children that aren't directly in the selection) + */ +export function flattenToExportEntries( + items: SceneItem[], + itemResolver?: (id: string) => SceneItem | undefined, +): ExportEntry[] { + const entries: ExportEntry[] = []; + const seen = new Set(); + + for (const item of items) { + if (seen.has(item.id)) continue; + seen.add(item.id); + + if (item.type === 'group') { + // Resolve group children into individual export entries. + // Children store local coords relative to the group parent, but + // getItemWorldBounds already accounts for parent transforms. + const groupData = item.data as GroupObject; + if (itemResolver) { + for (const childId of groupData.children) { + if (seen.has(childId)) continue; + seen.add(childId); + const child = itemResolver(childId); + if (!child) continue; + entries.push({ + id: child.id, + type: child.data.type, + z: child.data.z, + worldBounds: getItemWorldBounds(child), + data: child.data, + isGroupChild: true, + }); + } + } + continue; + } + + entries.push({ + id: item.id, + type: item.data.type, + z: item.data.z, + worldBounds: getItemWorldBounds(item), + data: item.data, + isGroupChild: false, + }); + } + + entries.sort((a, b) => a.z - b.z); + return entries; +} + +// --------------------------------------------------------------------------- +// Image composer +// --------------------------------------------------------------------------- + +const imageComposer: ItemComposer = { + supportsNative(item: ExportEntry): boolean { + // Group children store LOCAL coords relative to group parent, so we can't + // draw them with getImageDisplayTransform (which reads data.x/y directly). + // Fall back to snapshot for selections containing group children. + return item.type === 'image' && !item.isGroupChild; + }, + + async draw(ctx: CanvasRenderingContext2D, item: ExportEntry, env: DrawEnv): Promise { + const data = item.data as ImageObject; + const img = env.imageMap.get(item.id) ?? await loadImageForExport(data.asset); + const t = getImageDisplayTransform(data); + + // data.w/h may be capped display dimensions (e.g., 600px max for a 1920px + // source). Map the normalized crop rect through the real source pixel + // dimensions so we sample the full asset, not just the top-left corner. + const natW = img.naturalWidth || data.w; + const natH = img.naturalHeight || data.h; + const crop = data.crop ?? { x: 0, y: 0, w: 1, h: 1 }; + const srcX = crop.x * natW; + const srcY = crop.y * natH; + const srcW = crop.w * natW; + const srcH = crop.h * natH; + + ctx.save(); + ctx.translate( + t.x * env.pixelsPerWorld - env.offsetPxX, + t.y * env.pixelsPerWorld - env.offsetPxY, + ); + ctx.rotate((t.angle * Math.PI) / 180); + // The display transform scale (t.scaleX) places the image in world space + // using data.w-based local dimensions. ppw converts world→export pixels. + // We draw the full native source (srcW×srcH) and scale it to fit the same + // world-space footprint: world_w = visibleLocal.w * |sx|, so export_w = + // world_w * ppw. That means we scale the native source by: + // (visibleLocal.w * |sx| * ppw) / srcW = (data.w * crop.w * sx * ppw) / (natW * crop.w) + // = data.w * sx * ppw / natW + const flipX = data.flipX ? -1 : 1; + const flipY = data.flipY ? -1 : 1; + const drawScaleX = (data.w * Math.abs(data.sx) * env.pixelsPerWorld / natW) * flipX; + const drawScaleY = (data.h * Math.abs(data.sy) * env.pixelsPerWorld / natH) * flipY; + ctx.scale(drawScaleX, drawScaleY); + ctx.drawImage( + img, + srcX, srcY, srcW, srcH, + 0, 0, srcW, srcH, + ); + ctx.restore(); + }, +}; + +// --------------------------------------------------------------------------- +// Composer registry +// --------------------------------------------------------------------------- + +const composerRegistry: ItemComposer[] = [imageComposer]; + +function findComposer(entry: ExportEntry): ItemComposer | undefined { + return composerRegistry.find((c) => c.supportsNative(entry)); +} + +// --------------------------------------------------------------------------- +// Resolution policy +// --------------------------------------------------------------------------- + +/** + * Sync ppw estimate using data.w/h (for preview dimensions only). + * May underestimate for images with capped display dimensions. + */ +function estimatePixelsPerWorldSync(entries: ExportEntry[], scale: number): number { + let ppw = 1; + for (const entry of entries) { + if (entry.type !== 'image') continue; + const data = entry.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; + ppw = Math.max(ppw, pxPerWorldX, pxPerWorldY); + } + return Math.min(ppw * scale, 16); +} + +/** + * Compute native pixels-per-world-unit from loaded images. + * + * data.w/h can be capped display dimensions (e.g., 600px max), not the actual + * source resolution. We use the loaded image's naturalWidth/Height to compute + * the true native resolution per world unit. + */ +async function computePixelsPerWorld( + entries: ExportEntry[], + scale: number, +): Promise<{ ppw: number; imageMap: Map }> { + let ppw = 1; + const imageMap = new Map(); + + for (const entry of entries) { + if (entry.type !== 'image') continue; + const data = entry.data as ImageObject; + const img = await loadImageForExport(data.asset); + imageMap.set(entry.id, img); + + // True source pixels per world unit: natW / (data.w * sx) for the visible region + const natW = img.naturalWidth || data.w; + const natH = img.naturalHeight || data.h; + const pxPerWorldX = data.sx !== 0 ? natW / (data.w * Math.abs(data.sx)) : 1; + const pxPerWorldY = data.sy !== 0 ? natH / (data.h * Math.abs(data.sy)) : 1; + ppw = Math.max(ppw, pxPerWorldX, pxPerWorldY); + } + + return { ppw: Math.min(ppw * scale, 16), imageMap }; +} + +/** + * Ensure export dimensions fit within browser canvas limits. + * Returns adjusted pixelsPerWorld and any warning message. + */ +function clampToCanvasLimits( + ppw: number, + worldW: number, + worldH: number, + paddingPx: number, +): { pixelsPerWorld: number; warning: string | null } { + let w = Math.ceil(worldW * ppw) + paddingPx * 2; + let h = Math.ceil(worldH * ppw) + paddingPx * 2; + + if (w <= MAX_EXPORT_WIDTH && h <= MAX_EXPORT_HEIGHT && w * h <= MAX_TOTAL_PIXELS) { + return { pixelsPerWorld: ppw, warning: null }; + } + + // Scale down to fit + const scaleW = w > MAX_EXPORT_WIDTH ? (MAX_EXPORT_WIDTH - paddingPx * 2) / (worldW * ppw) * ppw : ppw; + const scaleH = h > MAX_EXPORT_HEIGHT ? (MAX_EXPORT_HEIGHT - paddingPx * 2) / (worldH * ppw) * ppw : ppw; + let newPpw = Math.min(scaleW, scaleH); + + // Check total pixel budget + w = Math.ceil(worldW * newPpw) + paddingPx * 2; + h = Math.ceil(worldH * newPpw) + paddingPx * 2; + if (w * h > MAX_TOTAL_PIXELS) { + const totalScale = Math.sqrt(MAX_TOTAL_PIXELS / (w * h)); + newPpw = newPpw * totalScale; + } + + return { + pixelsPerWorld: newPpw, + warning: 'Reduced resolution to fit browser limits', + }; +} + +// --------------------------------------------------------------------------- +// Main composition entry point +// --------------------------------------------------------------------------- + +/** + * Compose selected scene items into an offscreen canvas. + * + * Image-only selections produce native-resolution output from canonical scene + * data. Mixed selections fall back to the viewport snapshot renderer if + * `options.fallbackToSnapshot` is true, otherwise throw. + */ +export async function composeSelection( + items: SceneItem[], + options: CompositionOptions, + fallbackDeps?: { app: Application; viewport: Viewport }, + itemResolver?: (id: string) => SceneItem | undefined, +): Promise { + if (items.length === 0) { + throw new Error('No items to compose'); + } + + const entries = flattenToExportEntries(items, itemResolver); + const warnings: string[] = []; + + // If flattening produced no entries (e.g. group with no resolver), fall back + if (entries.length === 0) { + if (options.fallbackToSnapshot && fallbackDeps) { + const canvas = renderCanvasSnapshot( + fallbackDeps.app, + fallbackDeps.viewport, + items, + { background: options.background, paddingPx: options.paddingPx }, + ); + warnings.push('Copied using viewport-resolution fallback'); + return { canvas, native: false, warnings }; + } + throw new Error('No exportable items found'); + } + + // Check if all entries have native composers + const allNative = entries.every((e) => findComposer(e) !== undefined); + + if (!allNative) { + if (options.fallbackToSnapshot && fallbackDeps) { + const canvas = renderCanvasSnapshot( + fallbackDeps.app, + fallbackDeps.viewport, + items, + { background: options.background, paddingPx: options.paddingPx }, + ); + warnings.push('Copied using viewport-resolution fallback'); + return { canvas, native: false, warnings }; + } + throw new Error('Native composition unavailable for this selection and fallback disabled'); + } + + // Compute world bounds of all entries + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const entry of entries) { + const b = entry.worldBounds; + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + + const worldW = maxX - minX; + const worldH = maxY - minY; + + // Compute resolution — loads images to determine true native pixels + const { ppw: rawPpw, imageMap } = await computePixelsPerWorld(entries, options.scale); + const clamped = clampToCanvasLimits(rawPpw, worldW, worldH, options.paddingPx); + let ppw = clamped.pixelsPerWorld; + if (clamped.warning) warnings.push(clamped.warning); + + const offsetPxX = Math.floor(minX * ppw) - options.paddingPx; + const offsetPxY = Math.floor(minY * ppw) - options.paddingPx; + const canvasW = Math.max(1, Math.ceil(maxX * ppw) + options.paddingPx - offsetPxX); + const canvasH = Math.max(1, Math.ceil(maxY * ppw) + options.paddingPx - offsetPxY); + + const canvas = document.createElement('canvas'); + canvas.width = canvasW; + canvas.height = canvasH; + + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('2D context unavailable'); + + if (options.background) { + ctx.fillStyle = options.background; + ctx.fillRect(0, 0, canvasW, canvasH); + } else { + ctx.clearRect(0, 0, canvasW, canvasH); + } + + const env: DrawEnv = { pixelsPerWorld: ppw, offsetPxX, offsetPxY, imageMap }; + + for (const entry of entries) { + const composer = findComposer(entry); + if (!composer) continue; // should not happen — checked above + await composer.draw(ctx, entry, env); + } + + return { canvas, native: true, warnings }; +} + +// --------------------------------------------------------------------------- +// Dimension preview (for export dialog) +// --------------------------------------------------------------------------- + +export function getCompositionDimensions( + items: SceneItem[], + scale: number, + paddingPx: number, + itemResolver?: (id: string) => SceneItem | undefined, +): { width: number; height: number; native: boolean } { + if (items.length === 0) return { width: 0, height: 0, native: false }; + + const entries = flattenToExportEntries(items, itemResolver); + if (entries.length === 0) return { width: 0, height: 0, native: false }; + const allNative = entries.every((e) => findComposer(e) !== undefined); + + if (!allNative) { + // Fallback dimensions — world-space bounds at 1:1 + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const entry of entries) { + const b = entry.worldBounds; + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + const pad = paddingPx; + return { + width: Math.max(1, Math.round(maxX - minX) + pad * 2), + height: Math.max(1, Math.round(maxY - minY) + pad * 2), + native: false, + }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const entry of entries) { + const b = entry.worldBounds; + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + + const worldW = maxX - minX; + const worldH = maxY - minY; + // Sync ppw estimate for preview — uses data.w/h (may underestimate for + // capped images, but actual composition uses loaded naturalWidth/Height). + let ppw = estimatePixelsPerWorldSync(entries, scale); + const clamped = clampToCanvasLimits(ppw, worldW, worldH, paddingPx); + ppw = clamped.pixelsPerWorld; + + const offsetPxX = Math.floor(minX * ppw) - paddingPx; + const offsetPxY = Math.floor(minY * ppw) - paddingPx; + + return { + width: Math.max(1, Math.ceil(maxX * ppw) + paddingPx - offsetPxX), + height: Math.max(1, Math.ceil(maxY * ppw) + paddingPx - offsetPxY), + native: true, + }; +} diff --git a/frontend/src/canvas/export.ts b/frontend/src/canvas/export.ts index 459bed3..f1298c4 100644 --- a/frontend/src/canvas/export.ts +++ b/frontend/src/canvas/export.ts @@ -1,20 +1,15 @@ /** * Export canvas content as a downloadable image file. * - * Image-only selections use the native-resolution renderer based on canonical - * image geometry. Mixed selections fall back to the rendered canvas snapshot. + * Uses the unified composition renderer. Image-only selections produce + * native-resolution output; mixed selections fall back to the viewport + * snapshot with an explicit warning. */ import type { Viewport } from 'pixi-viewport'; import type { Application } from 'pixi.js'; -import type { SceneItem } from './SceneManager'; -import { getItemWorldBounds } from './SceneManager'; -import { - getNativeImageExportDimensions, - renderCanvasSnapshot, - renderNativeImageSelection, - supportsNativeImageExport, -} from './snapshotRenderer'; +import type { SceneManager, SceneItem } from './SceneManager'; +import { composeSelection, getCompositionDimensions } from './compositionRenderer'; export interface ExportOptions { format: 'png' | 'jpeg' | 'webp'; @@ -30,41 +25,35 @@ const MIME_MAP = { webp: 'image/webp', } as const; -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 selected items as a downloadable image file. + * Returns any warnings from the composition pipeline. + */ export async function exportAsImage( app: Application | null, viewport: Viewport | null, items: SceneItem[], options: ExportOptions, -): Promise { + scene?: SceneManager, +): Promise { if (!app || !viewport) throw new Error('Renderer not available'); if (items.length === 0) throw new Error('No items to export'); - const canvas = await renderToCanvas(app, viewport, items, options); + const resolver = scene ? (id: string) => scene.getById(id) : undefined; + + const result = await composeSelection(items, { + mode: 'export', + scale: options.scale, + background: options.background, + paddingPx: 10, + fallbackToSnapshot: true, + }, { app, viewport }, resolver); + const mimeType = MIME_MAP[options.format]; const quality = options.format === 'png' ? undefined : options.quality; const blob = await new Promise((resolve, reject) => { - canvas.toBlob( + result.canvas.toBlob( (b) => (b ? resolve(b) : reject(new Error('Export failed'))), mimeType, quality, @@ -79,33 +68,17 @@ export async function exportAsImage( a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + + return result.warnings; } export function getExportDimensions( items: SceneItem[], scale: number, + scene?: SceneManager, ): { width: number; height: number } { if (items.length === 0) return { width: 0, height: 0 }; - - 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); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x + w); - maxY = Math.max(maxY, y + h); - } - - const pad = 12; - return { - width: Math.max(1, Math.round(maxX - minX) + pad * 2), - height: Math.max(1, Math.round(maxY - minY) + pad * 2), - }; + const resolver = scene ? (id: string) => scene.getById(id) : undefined; + const dims = getCompositionDimensions(items, scale, 10, resolver); + return { width: dims.width, height: dims.height }; } diff --git a/frontend/src/canvas/sprites/VideoSprite.ts b/frontend/src/canvas/sprites/VideoSprite.ts index abe0094..2068dd3 100644 --- a/frontend/src/canvas/sprites/VideoSprite.ts +++ b/frontend/src/canvas/sprites/VideoSprite.ts @@ -544,6 +544,9 @@ export class VideoSprite extends Container { /** Draw current video frame to offscreen canvas, then tell PixiJS to re-upload. */ private _drawFrame(): void { if (!this._frameCtx || !this._frameCanvas || !this.videoEl || !this.videoTexture) return; + // Guard against destroyed texture source — the frame loop callback can fire + // after _destroyVideoTexture() runs during zoom-triggered culling. + if (!this.videoTexture.source) return; this._frameCtx.drawImage(this.videoEl, 0, 0, this._frameCanvas.width, this._frameCanvas.height); this.videoTexture.source.update(); } diff --git a/frontend/src/hooks/useShortcutHandler.ts b/frontend/src/hooks/useShortcutHandler.ts index 1cf273b..e90a246 100644 --- a/frontend/src/hooks/useShortcutHandler.ts +++ b/frontend/src/hooks/useShortcutHandler.ts @@ -80,7 +80,8 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) { writeCanvasToClipboard: async (items?: SceneItem[]) => { try { const app = canvasRef.current?.getApp() ?? null; - await writeCanvasToClipboard(app, viewport, items); + const scene = canvasRef.current?.getScene() ?? undefined; + await writeCanvasToClipboard(app, viewport, items, scene); } catch (err: any) { showToast('Copy failed: ' + (err.message || 'clipboard not available')); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index d07c5bc..8479435 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -586,7 +586,8 @@ export default function Editor({ isPublicView }: EditorProps) { try { const app = canvasRef.current?.getApp() ?? null; const viewport = canvasRef.current?.getViewport() ?? null; - await writeClipboard(app, viewport, items); + const scene = canvasRef.current?.getScene() ?? undefined; + await writeClipboard(app, viewport, items, scene); } catch (err: any) { showToast('Copy failed: ' + (err.message || 'clipboard not available')); } @@ -1622,7 +1623,8 @@ export default function Editor({ isPublicView }: EditorProps) { try { const app = canvasRef.current?.getApp() ?? null; const viewport = canvasRef.current?.getViewport() ?? null; - await exportAsImage(app, viewport, items, options); + const scene = canvasRef.current?.getScene() ?? undefined; + await exportAsImage(app, viewport, items, options, scene); showToast('Exported successfully'); } catch (err: any) { showToast('Export failed: ' + (err.message || 'unknown error'));