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:
@@ -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<void> {
|
||||
scene?: SceneManager,
|
||||
): Promise<string[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<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 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<void> {
|
||||
scene?: SceneManager,
|
||||
): Promise<string[]> {
|
||||
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<Blob>((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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user