feat: unified composition renderer for clipboard/export

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
This commit is contained in:
Hiren Kangad
2026-03-13 16:59:11 +05:30
parent 1ceafec67f
commit 05118330d6
7 changed files with 805 additions and 72 deletions
+23 -14
View File
@@ -1,42 +1,49 @@
/** /**
* Clipboard module — copy canvas to system clipboard and paste images from it. * Clipboard module — copy canvas to system clipboard and paste images from it.
* *
* Copy approach: snapshot the already-rendered canvas, then crop the visible * Uses the unified composition renderer for all copy operations. Image-only
* selection region in screen space. This preserves crop/flip/rotation/group * selections produce native-resolution output; mixed selections fall back to
* transforms exactly as the user sees them. * the viewport snapshot with an explicit warning.
*/ */
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 { uploadImage } from '../api'; import { uploadImage } from '../api';
import { import { composeSelection } from './compositionRenderer';
renderCanvasSnapshot, import { renderCanvasSnapshot } from './snapshotRenderer';
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.
* Selected items are rendered at their native size, not affected by zoom. * 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( export async function writeCanvasToClipboard(
app: Application | null, app: Application | null,
viewport: Viewport | null, viewport: Viewport | null,
items?: SceneItem[], items?: SceneItem[],
): Promise<void> { scene?: SceneManager,
): Promise<string[]> {
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');
let outputCanvas: HTMLCanvasElement; let outputCanvas: HTMLCanvasElement;
let warnings: string[] = [];
const resolver = scene ? (id: string) => scene.getById(id) : undefined;
if (items && items.length > 0) { if (items && items.length > 0) {
if (supportsNativeImageExport(items)) { const result = await composeSelection(items, {
outputCanvas = await renderNativeImageSelection(items, { background: null, paddingPx: 10 }); mode: 'clipboard',
} else { scale: 1,
outputCanvas = renderCanvasSnapshot(app, viewport, items, { background: null, paddingPx: 12 }); background: null,
} paddingPx: 10,
fallbackToSnapshot: true,
}, { app, viewport }, resolver);
outputCanvas = result.canvas;
warnings = result.warnings;
} else { } else {
// Full viewport capture — always snapshot
outputCanvas = renderCanvasSnapshot(app, viewport, undefined, { background: null }); outputCanvas = renderCanvasSnapshot(app, viewport, undefined, { background: null });
} }
@@ -50,6 +57,8 @@ export async function writeCanvasToClipboard(
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }), new ClipboardItem({ 'image/png': blob }),
]); ]);
return warnings;
} }
/** /**
@@ -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<ImageObject> = {}): 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<AnySceneObject> = {}): 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);
});
});
+499
View File
@@ -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<void>;
}
export interface DrawEnv {
pixelsPerWorld: number;
offsetPxX: number;
offsetPxY: number;
/** Pre-loaded images keyed by entry ID. */
imageMap: Map<string, HTMLImageElement>;
}
// ---------------------------------------------------------------------------
// 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<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 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<string>();
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<void> {
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<string, HTMLImageElement> }> {
let ppw = 1;
const imageMap = new Map<string, HTMLImageElement>();
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<CompositionResult> {
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,
};
}
+28 -55
View File
@@ -1,20 +1,15 @@
/** /**
* Export canvas content as a downloadable image file. * Export canvas content as a downloadable image file.
* *
* Image-only selections use the native-resolution renderer based on canonical * Uses the unified composition renderer. Image-only selections produce
* image geometry. Mixed selections fall back to the rendered canvas snapshot. * native-resolution output; mixed selections fall back to the viewport
* snapshot with an explicit warning.
*/ */
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 { SceneManager, SceneItem } from './SceneManager';
import { getItemWorldBounds } from './SceneManager'; import { composeSelection, getCompositionDimensions } from './compositionRenderer';
import {
getNativeImageExportDimensions,
renderCanvasSnapshot,
renderNativeImageSelection,
supportsNativeImageExport,
} from './snapshotRenderer';
export interface ExportOptions { export interface ExportOptions {
format: 'png' | 'jpeg' | 'webp'; format: 'png' | 'jpeg' | 'webp';
@@ -30,41 +25,35 @@ const MIME_MAP = {
webp: 'image/webp', webp: 'image/webp',
} as const; } as const;
async function renderToCanvas( /**
app: Application, * Export selected items as a downloadable image file.
viewport: Viewport, * Returns any warnings from the composition pipeline.
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> { scene?: SceneManager,
): Promise<string[]> {
if (!app || !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 = 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 mimeType = MIME_MAP[options.format];
const quality = options.format === 'png' ? undefined : options.quality; const quality = options.format === 'png' ? undefined : options.quality;
const blob = await new Promise<Blob>((resolve, reject) => { const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob( result.canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Export failed'))), (b) => (b ? resolve(b) : reject(new Error('Export failed'))),
mimeType, mimeType,
quality, quality,
@@ -79,33 +68,17 @@ export async function exportAsImage(
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
return result.warnings;
} }
export function getExportDimensions( export function getExportDimensions(
items: SceneItem[], items: SceneItem[],
scale: number, scale: number,
scene?: SceneManager,
): { 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 };
const resolver = scene ? (id: string) => scene.getById(id) : undefined;
if (supportsNativeImageExport(items)) { const dims = getCompositionDimensions(items, scale, 10, resolver);
return getNativeImageExportDimensions(items, scale, 10); return { width: dims.width, height: dims.height };
}
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),
};
} }
@@ -544,6 +544,9 @@ export class VideoSprite extends Container {
/** Draw current video frame to offscreen canvas, then tell PixiJS to re-upload. */ /** Draw current video frame to offscreen canvas, then tell PixiJS to re-upload. */
private _drawFrame(): void { private _drawFrame(): void {
if (!this._frameCtx || !this._frameCanvas || !this.videoEl || !this.videoTexture) return; 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._frameCtx.drawImage(this.videoEl, 0, 0, this._frameCanvas.width, this._frameCanvas.height);
this.videoTexture.source.update(); this.videoTexture.source.update();
} }
+2 -1
View File
@@ -80,7 +80,8 @@ export function useShortcutHandler(deps: ShortcutHandlerDeps) {
writeCanvasToClipboard: async (items?: SceneItem[]) => { writeCanvasToClipboard: async (items?: SceneItem[]) => {
try { try {
const app = canvasRef.current?.getApp() ?? null; 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) { } catch (err: any) {
showToast('Copy failed: ' + (err.message || 'clipboard not available')); showToast('Copy failed: ' + (err.message || 'clipboard not available'));
} }
+4 -2
View File
@@ -586,7 +586,8 @@ export default function Editor({ isPublicView }: EditorProps) {
try { try {
const app = canvasRef.current?.getApp() ?? null; const app = canvasRef.current?.getApp() ?? null;
const viewport = canvasRef.current?.getViewport() ?? 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) { } catch (err: any) {
showToast('Copy failed: ' + (err.message || 'clipboard not available')); showToast('Copy failed: ' + (err.message || 'clipboard not available'));
} }
@@ -1622,7 +1623,8 @@ export default function Editor({ isPublicView }: EditorProps) {
try { try {
const app = canvasRef.current?.getApp() ?? null; const app = canvasRef.current?.getApp() ?? null;
const viewport = canvasRef.current?.getViewport() ?? 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'); showToast('Exported successfully');
} catch (err: any) { } catch (err: any) {
showToast('Export failed: ' + (err.message || 'unknown error')); showToast('Export failed: ' + (err.message || 'unknown error'));