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.
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
* 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user