refboard: add native image copy and export renderer
This commit is contained in:
@@ -9,8 +9,12 @@
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { SceneManager, SceneItem } from './SceneManager';
|
||||
import { getItemWorldBounds } from './SceneManager';
|
||||
import { uploadImage } from '../api';
|
||||
import {
|
||||
renderCanvasSnapshot,
|
||||
renderNativeImageSelection,
|
||||
supportsNativeImageExport,
|
||||
} from './snapshotRenderer';
|
||||
|
||||
/**
|
||||
* Write selected items (or full viewport) to system clipboard as PNG.
|
||||
@@ -24,53 +28,16 @@ export async function writeCanvasToClipboard(
|
||||
if (!viewport) throw new Error('Viewport not available');
|
||||
if (!app) throw new Error('Renderer not available');
|
||||
|
||||
const directCanvas = app.canvas as unknown as HTMLCanvasElement | undefined;
|
||||
const sourceCanvas = directCanvas
|
||||
?? (app.renderer?.extract
|
||||
? (app.renderer.extract.canvas(viewport) as HTMLCanvasElement)
|
||||
: undefined);
|
||||
if (!sourceCanvas || sourceCanvas.width <= 0 || sourceCanvas.height <= 0) {
|
||||
throw new Error('Rendered canvas unavailable');
|
||||
}
|
||||
|
||||
let outputCanvas: HTMLCanvasElement;
|
||||
|
||||
if (items && items.length > 0) {
|
||||
// Compute world-space bounding box, then convert to visible screen-space crop.
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const { x, y, w, h } = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + w);
|
||||
maxY = Math.max(maxY, y + h);
|
||||
if (supportsNativeImageExport(items)) {
|
||||
outputCanvas = await renderNativeImageSelection(items, { background: null, paddingPx: 10 });
|
||||
} else {
|
||||
outputCanvas = renderCanvasSnapshot(app, viewport, items, { background: null, paddingPx: 12 });
|
||||
}
|
||||
|
||||
const screenTL = viewport.toScreen(minX, minY);
|
||||
const screenBR = viewport.toScreen(maxX, maxY);
|
||||
const rect = sourceCanvas.getBoundingClientRect();
|
||||
const scaleX = rect.width > 0 ? sourceCanvas.width / rect.width : 1;
|
||||
const scaleY = rect.height > 0 ? sourceCanvas.height / rect.height : 1;
|
||||
const pad = 12;
|
||||
const sx = Math.max(0, Math.floor(Math.min(screenTL.x, screenBR.x) * scaleX) - pad);
|
||||
const sy = Math.max(0, Math.floor(Math.min(screenTL.y, screenBR.y) * scaleY) - pad);
|
||||
const sw = Math.max(1, Math.min(sourceCanvas.width - sx, Math.ceil(Math.abs(screenBR.x - screenTL.x) * scaleX) + pad * 2));
|
||||
const sh = Math.max(1, Math.min(sourceCanvas.height - sy, Math.ceil(Math.abs(screenBR.y - screenTL.y) * scaleY) + pad * 2));
|
||||
|
||||
outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = sw;
|
||||
outputCanvas.height = sh;
|
||||
const ctx2d = outputCanvas.getContext('2d')!;
|
||||
ctx2d.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||
ctx2d.drawImage(sourceCanvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
} else {
|
||||
// Full current view copy.
|
||||
outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = sourceCanvas.width;
|
||||
outputCanvas.height = sourceCanvas.height;
|
||||
const ctx2d = outputCanvas.getContext('2d')!;
|
||||
ctx2d.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||
ctx2d.drawImage(sourceCanvas, 0, 0);
|
||||
outputCanvas = renderCanvasSnapshot(app, viewport, undefined, { background: null });
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
|
||||
+45
-131
@@ -1,137 +1,65 @@
|
||||
/**
|
||||
* Export canvas content as a downloadable image file.
|
||||
* Reuses the same rendering pipeline as clipboard.ts.
|
||||
*
|
||||
* Image-only selections use the native-resolution renderer based on canonical
|
||||
* image geometry. Mixed selections fall back to the rendered canvas snapshot.
|
||||
*/
|
||||
|
||||
import { Container, Rectangle } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { SceneItem } from './SceneManager';
|
||||
import { getItemWorldBounds } from './SceneManager';
|
||||
import { getImageVisibleLocalRect } from './imageTransforms';
|
||||
import type { ImageObject } from './scene-format';
|
||||
import {
|
||||
getNativeImageExportDimensions,
|
||||
renderCanvasSnapshot,
|
||||
renderNativeImageSelection,
|
||||
supportsNativeImageExport,
|
||||
} from './snapshotRenderer';
|
||||
|
||||
export interface ExportOptions {
|
||||
format: 'png' | 'jpeg' | 'webp';
|
||||
quality: number; // 0-1, only used for jpeg/webp
|
||||
scale: number; // 1 = native, 0.5 = half, 2 = double
|
||||
background: string; // hex color
|
||||
quality: number;
|
||||
scale: number;
|
||||
background: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render selected items (or full board content) to a canvas at the given scale.
|
||||
* Returns an HTMLCanvasElement ready for blob conversion.
|
||||
*/
|
||||
function renderToCanvas(
|
||||
app: Application,
|
||||
viewport: Viewport,
|
||||
items: SceneItem[],
|
||||
scale: number,
|
||||
background: string,
|
||||
): HTMLCanvasElement {
|
||||
// Compute world-space bounding box
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const { x, y, w, h } = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + w);
|
||||
maxY = Math.max(maxY, y + h);
|
||||
}
|
||||
|
||||
const pad = 10;
|
||||
const totalW = Math.ceil(maxX - minX) + pad * 2;
|
||||
const totalH = Math.ceil(maxY - minY) + pad * 2;
|
||||
|
||||
// Reparent items into temp container
|
||||
const tempContainer = new Container();
|
||||
const saved = new Map<string, { parent: Container; x: number; y: number; sx: number; sy: number; angle: number; globalX: number; globalY: number }>();
|
||||
for (const item of items) {
|
||||
const obj = item.displayObject;
|
||||
const globalOrigin = obj.parent?.toGlobal(obj.position) ?? obj.position;
|
||||
saved.set(item.id, {
|
||||
parent: obj.parent as Container,
|
||||
x: obj.x, y: obj.y,
|
||||
sx: obj.scale.x, sy: obj.scale.y,
|
||||
angle: obj.angle,
|
||||
globalX: globalOrigin.x,
|
||||
globalY: globalOrigin.y,
|
||||
});
|
||||
obj.parent?.removeChild(obj);
|
||||
const s = saved.get(item.id)!;
|
||||
obj.position.set(s.globalX - minX + pad, s.globalY - minY + pad);
|
||||
obj.scale.set(s.sx, s.sy);
|
||||
obj.angle = s.angle;
|
||||
tempContainer.addChild(obj);
|
||||
}
|
||||
|
||||
// Compute resolution: use native texture ratio, then apply user scale
|
||||
let maxRatio = 1;
|
||||
for (const item of items) {
|
||||
const tex = (item.displayObject as any)?.texture;
|
||||
const displayW = item.type === 'image'
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||
: item.data.w * Math.abs(item.data.sx);
|
||||
if (tex && tex.width > 1 && displayW > 1) {
|
||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||
}
|
||||
}
|
||||
const resolution = Math.min(maxRatio * scale, 8); // cap at 8x to avoid GPU limits
|
||||
|
||||
let texture;
|
||||
let extractedCanvas: HTMLCanvasElement;
|
||||
try {
|
||||
texture = app.renderer.generateTexture({
|
||||
target: tempContainer,
|
||||
resolution,
|
||||
frame: new Rectangle(0, 0, totalW, totalH),
|
||||
});
|
||||
extractedCanvas = app.renderer.extract.canvas(texture) as HTMLCanvasElement;
|
||||
} finally {
|
||||
for (const item of items) {
|
||||
const s = saved.get(item.id)!;
|
||||
tempContainer.removeChild(item.displayObject);
|
||||
s.parent.addChild(item.displayObject);
|
||||
item.displayObject.position.set(s.x, s.y);
|
||||
item.displayObject.scale.set(s.sx, s.sy);
|
||||
item.displayObject.angle = s.angle;
|
||||
}
|
||||
tempContainer.destroy();
|
||||
texture?.destroy(true);
|
||||
}
|
||||
|
||||
// Add background
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = extractedCanvas.width;
|
||||
outputCanvas.height = extractedCanvas.height;
|
||||
const ctx = outputCanvas.getContext('2d')!;
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||
ctx.drawImage(extractedCanvas, 0, 0);
|
||||
|
||||
return outputCanvas;
|
||||
}
|
||||
|
||||
const MIME_MAP = {
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Export items as a downloadable image.
|
||||
*/
|
||||
async function renderToCanvas(
|
||||
app: Application,
|
||||
viewport: Viewport,
|
||||
items: SceneItem[],
|
||||
options: ExportOptions,
|
||||
): Promise<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(
|
||||
app: Application | null,
|
||||
viewport: Viewport | null,
|
||||
items: SceneItem[],
|
||||
options: ExportOptions,
|
||||
): Promise<void> {
|
||||
if (!app?.renderer?.extract || !viewport) throw new Error('Renderer not available');
|
||||
if (!app || !viewport) throw new Error('Renderer not available');
|
||||
if (items.length === 0) throw new Error('No items to export');
|
||||
|
||||
const canvas = renderToCanvas(app, viewport, items, options.scale, options.background);
|
||||
const canvas = await renderToCanvas(app, viewport, items, options);
|
||||
const mimeType = MIME_MAP[options.format];
|
||||
const quality = options.format === 'png' ? undefined : options.quality;
|
||||
|
||||
@@ -143,7 +71,6 @@ export async function exportAsImage(
|
||||
);
|
||||
});
|
||||
|
||||
// Trigger browser download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -154,17 +81,20 @@ export async function exportAsImage(
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pixel dimensions of the export at a given scale,
|
||||
* so the dialog can show a preview of the output size.
|
||||
*/
|
||||
export function getExportDimensions(
|
||||
items: SceneItem[],
|
||||
scale: number,
|
||||
): { width: number; height: number } {
|
||||
if (items.length === 0) return { width: 0, height: 0 };
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
if (supportsNativeImageExport(items)) {
|
||||
return getNativeImageExportDimensions(items, scale, 10);
|
||||
}
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const { x, y, w, h } = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, x);
|
||||
@@ -173,25 +103,9 @@ export function getExportDimensions(
|
||||
maxY = Math.max(maxY, y + h);
|
||||
}
|
||||
|
||||
const pad = 10;
|
||||
const w = Math.ceil(maxX - minX) + pad * 2;
|
||||
const h = Math.ceil(maxY - minY) + pad * 2;
|
||||
|
||||
// Estimate native resolution ratio
|
||||
let maxRatio = 1;
|
||||
for (const item of items) {
|
||||
const tex = (item.displayObject as any)?.texture;
|
||||
const displayW = item.type === 'image'
|
||||
? getImageVisibleLocalRect(item.data as ImageObject).w * Math.abs(item.data.sx)
|
||||
: item.data.w * Math.abs(item.data.sx);
|
||||
if (tex && tex.width > 1 && displayW > 1) {
|
||||
maxRatio = Math.max(maxRatio, tex.width / displayW);
|
||||
}
|
||||
}
|
||||
const resolution = Math.min(maxRatio * scale, 8);
|
||||
|
||||
const pad = 12;
|
||||
return {
|
||||
width: Math.round(w * resolution),
|
||||
height: Math.round(h * resolution),
|
||||
width: Math.max(1, Math.round(maxX - minX) + pad * 2),
|
||||
height: Math.max(1, Math.round(maxY - minY) + pad * 2),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { SceneItem } from './SceneManager';
|
||||
import { getItemWorldBounds } from './SceneManager';
|
||||
import {
|
||||
getImageDisplayTransform,
|
||||
getImageSourceRect,
|
||||
} from './imageTransforms';
|
||||
import type { ImageObject } from './scene-format';
|
||||
|
||||
const imageCache = new Map<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 function supportsNativeImageExport(items: SceneItem[]): boolean {
|
||||
return items.length > 0 && items.every((item) => item.type === 'image');
|
||||
}
|
||||
|
||||
function getNativePixelsPerWorld(items: SceneItem[], scale = 1): number {
|
||||
let pixelsPerWorld = 1;
|
||||
for (const item of items) {
|
||||
if (item.type !== 'image') continue;
|
||||
const data = item.data as ImageObject;
|
||||
const pxPerWorldX = data.sx !== 0 ? 1 / Math.abs(data.sx) : 1;
|
||||
const pxPerWorldY = data.sy !== 0 ? 1 / Math.abs(data.sy) : 1;
|
||||
pixelsPerWorld = Math.max(pixelsPerWorld, pxPerWorldX, pxPerWorldY);
|
||||
}
|
||||
return Math.min(pixelsPerWorld * scale, 8);
|
||||
}
|
||||
|
||||
export async function renderNativeImageSelection(
|
||||
items: SceneItem[],
|
||||
options?: { scale?: number; background?: string | null; paddingPx?: number },
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!supportsNativeImageExport(items)) {
|
||||
throw new Error('Native export currently supports image-only selections');
|
||||
}
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const bounds = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, bounds.x);
|
||||
minY = Math.min(minY, bounds.y);
|
||||
maxX = Math.max(maxX, bounds.x + bounds.w);
|
||||
maxY = Math.max(maxY, bounds.y + bounds.h);
|
||||
}
|
||||
|
||||
const pixelsPerWorld = getNativePixelsPerWorld(items, options?.scale ?? 1);
|
||||
const paddingPx = options?.paddingPx ?? 10;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.ceil((maxX - minX) * pixelsPerWorld) + paddingPx * 2);
|
||||
canvas.height = Math.max(1, Math.ceil((maxY - minY) * pixelsPerWorld) + paddingPx * 2);
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('2D context unavailable');
|
||||
|
||||
if (options?.background) {
|
||||
ctx.fillStyle = options.background;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const orderedItems = [...items].sort((a, b) => a.data.z - b.data.z);
|
||||
|
||||
for (const item of orderedItems) {
|
||||
const data = item.data as ImageObject;
|
||||
const img = await loadImageForExport(data.asset);
|
||||
const src = getImageSourceRect(data);
|
||||
const t = getImageDisplayTransform(data);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
Math.round((t.x - minX) * pixelsPerWorld) + paddingPx,
|
||||
Math.round((t.y - minY) * pixelsPerWorld) + paddingPx,
|
||||
);
|
||||
ctx.rotate((t.angle * Math.PI) / 180);
|
||||
ctx.scale(t.scaleX * pixelsPerWorld, t.scaleY * pixelsPerWorld);
|
||||
ctx.drawImage(
|
||||
img,
|
||||
src.x,
|
||||
src.y,
|
||||
src.w,
|
||||
src.h,
|
||||
0,
|
||||
0,
|
||||
src.w,
|
||||
src.h,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function getNativeImageExportDimensions(
|
||||
items: SceneItem[],
|
||||
scale = 1,
|
||||
paddingPx = 10,
|
||||
): { width: number; height: number } {
|
||||
if (!supportsNativeImageExport(items)) return { width: 0, height: 0 };
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const bounds = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, bounds.x);
|
||||
minY = Math.min(minY, bounds.y);
|
||||
maxX = Math.max(maxX, bounds.x + bounds.w);
|
||||
maxY = Math.max(maxY, bounds.y + bounds.h);
|
||||
}
|
||||
|
||||
const pixelsPerWorld = getNativePixelsPerWorld(items, scale);
|
||||
return {
|
||||
width: Math.max(1, Math.ceil((maxX - minX) * pixelsPerWorld) + paddingPx * 2),
|
||||
height: Math.max(1, Math.ceil((maxY - minY) * pixelsPerWorld) + paddingPx * 2),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderCanvasSnapshot(
|
||||
app: Application,
|
||||
viewport: Viewport,
|
||||
items?: SceneItem[],
|
||||
options?: { background?: string | null; paddingPx?: number },
|
||||
): HTMLCanvasElement {
|
||||
const directCanvas = app.canvas as unknown as HTMLCanvasElement | undefined;
|
||||
const sourceCanvas = directCanvas
|
||||
?? (app.renderer?.extract
|
||||
? (app.renderer.extract.canvas(viewport) as HTMLCanvasElement)
|
||||
: undefined);
|
||||
if (!sourceCanvas || sourceCanvas.width <= 0 || sourceCanvas.height <= 0) {
|
||||
throw new Error('Rendered canvas unavailable');
|
||||
}
|
||||
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sw = sourceCanvas.width;
|
||||
let sh = sourceCanvas.height;
|
||||
|
||||
if (items && items.length > 0) {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const item of items) {
|
||||
const bounds = getItemWorldBounds(item);
|
||||
minX = Math.min(minX, bounds.x);
|
||||
minY = Math.min(minY, bounds.y);
|
||||
maxX = Math.max(maxX, bounds.x + bounds.w);
|
||||
maxY = Math.max(maxY, bounds.y + bounds.h);
|
||||
}
|
||||
|
||||
const screenTL = viewport.toScreen(minX, minY);
|
||||
const screenBR = viewport.toScreen(maxX, maxY);
|
||||
const rect = sourceCanvas.getBoundingClientRect();
|
||||
const scaleX = rect.width > 0 ? sourceCanvas.width / rect.width : 1;
|
||||
const scaleY = rect.height > 0 ? sourceCanvas.height / rect.height : 1;
|
||||
const pad = options?.paddingPx ?? 12;
|
||||
|
||||
sx = Math.max(0, Math.floor(Math.min(screenTL.x, screenBR.x) * scaleX) - pad);
|
||||
sy = Math.max(0, Math.floor(Math.min(screenTL.y, screenBR.y) * scaleY) - pad);
|
||||
sw = Math.max(1, Math.min(sourceCanvas.width - sx, Math.ceil(Math.abs(screenBR.x - screenTL.x) * scaleX) + pad * 2));
|
||||
sh = Math.max(1, Math.min(sourceCanvas.height - sy, Math.ceil(Math.abs(screenBR.y - screenTL.y) * scaleY) + pad * 2));
|
||||
}
|
||||
|
||||
const outputCanvas = document.createElement('canvas');
|
||||
outputCanvas.width = sw;
|
||||
outputCanvas.height = sh;
|
||||
const ctx = outputCanvas.getContext('2d');
|
||||
if (!ctx) throw new Error('2D context unavailable');
|
||||
|
||||
if (options?.background) {
|
||||
ctx.fillStyle = options.background;
|
||||
ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
|
||||
}
|
||||
|
||||
ctx.drawImage(sourceCanvas, sx, sy, sw, sh, 0, 0, sw, sh);
|
||||
return outputCanvas;
|
||||
}
|
||||
Reference in New Issue
Block a user