From 103c5617cf1c8f7abe5dd11ec756e2dd0c9fd6fb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 00:11:48 +0300 Subject: [PATCH] Re-encode JPG/WEBP via OffscreenCanvas instead of CanvasKit The default CanvasKit WASM build lacks JPEG/WEBP encoders. Instead of switching to the full build (+1MB), render PNG via CanvasKit then re-encode to the target format using OffscreenCanvas.convertToBlob(). --- src/components/properties/ExportSection.vue | 4 +- src/engine/render-image.ts | 127 +++++++++++--------- src/stores/editor.ts | 10 +- vite.config.ts | 2 +- 4 files changed, 80 insertions(+), 63 deletions(-) diff --git a/src/components/properties/ExportSection.vue b/src/components/properties/ExportSection.vue index 6e59c6693..bc6f939eb 100644 --- a/src/components/properties/ExportSection.vue +++ b/src/components/properties/ExportSection.vue @@ -52,7 +52,7 @@ async function doExport() { const PREVIEW_WIDTH = 480 -function updatePreview() { +async function updatePreview() { if (!showPreview.value) return if (previewUrl.value) { URL.revokeObjectURL(previewUrl.value) @@ -68,7 +68,7 @@ function updatePreview() { } const scale = maxW > 0 ? Math.min(PREVIEW_WIDTH / maxW, 2) : 1 - const data = store.renderExportImage(ids, scale, 'PNG') + const data = await store.renderExportImage(ids, scale, 'PNG') if (data) { previewUrl.value = URL.createObjectURL(new Blob([data], { type: 'image/png' })) } diff --git a/src/engine/render-image.ts b/src/engine/render-image.ts index bf7bad195..ac731e1db 100644 --- a/src/engine/render-image.ts +++ b/src/engine/render-image.ts @@ -1,6 +1,6 @@ import type { SkiaRenderer } from './renderer' import type { SceneGraph } from './scene-graph' -import type { CanvasKit, EncodedImageFormat } from 'canvaskit-wasm' +import type { CanvasKit } from 'canvaskit-wasm' export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' @@ -33,29 +33,79 @@ function computeContentBounds( return { minX, minY, maxX, maxY } } -function ckImageFormat(ck: CanvasKit, format: ExportFormat): EncodedImageFormat { - switch (format) { - case 'JPG': - return ck.ImageFormat.JPEG - case 'WEBP': - return ck.ImageFormat.WEBP - default: - return ck.ImageFormat.PNG +function renderToSurface( + ck: CanvasKit, + renderer: SkiaRenderer, + graph: SceneGraph, + pageId: string, + width: number, + height: number, + setup: (canvas: import('canvaskit-wasm').Canvas) => void +): Uint8Array | null { + const surface = ck.MakeSurface(width, height) + if (!surface) return null + + try { + const canvas = surface.getCanvas() + setup(canvas) + renderer.renderSceneToCanvas(canvas, graph, pageId) + surface.flush() + const image = surface.makeImageSnapshot() + const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 100) + image.delete() + return encoded ? new Uint8Array(encoded) : null + } finally { + surface.delete() } } -function defaultQuality(format: ExportFormat): number { - return format === 'PNG' ? 100 : 90 +function mimeForFormat(format: ExportFormat): string { + switch (format) { + case 'JPG': + return 'image/jpeg' + case 'WEBP': + return 'image/webp' + default: + return 'image/png' + } } -export function renderNodesToImage( +async function reencodeImage( + pngBytes: Uint8Array, + format: ExportFormat, + quality: number +): Promise { + if (format === 'PNG') return pngBytes + + const blob = new Blob([new Uint8Array(pngBytes)], { type: 'image/png' }) + const bitmap = await createImageBitmap(blob) + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height) + const ctx = canvas.getContext('2d') + if (!ctx) return pngBytes + + if (format === 'JPG') { + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, bitmap.width, bitmap.height) + } + + ctx.drawImage(bitmap, 0, 0) + bitmap.close() + + const outBlob = await canvas.convertToBlob({ + type: mimeForFormat(format), + quality: quality / 100 + }) + return new Uint8Array(await outBlob.arrayBuffer()) +} + +export async function renderNodesToImage( ck: CanvasKit, renderer: SkiaRenderer, graph: SceneGraph, pageId: string, nodeIds: string[], options: RenderOptions -): Uint8Array | null { +): Promise { const bounds = computeContentBounds(graph, nodeIds) if (!bounds) return null @@ -67,36 +117,15 @@ export function renderNodesToImage( const pixelH = Math.ceil(contentH * options.scale) if (pixelW <= 0 || pixelH <= 0) return null - const surface = ck.MakeSurface(pixelW, pixelH) - if (!surface) return null - - try { - const canvas = surface.getCanvas() - - if (options.format === 'JPG') { - canvas.clear(ck.WHITE) - } else { - canvas.clear(ck.TRANSPARENT) - } - + const png = renderToSurface(ck, renderer, graph, pageId, pixelW, pixelH, (canvas) => { + canvas.clear(ck.TRANSPARENT) canvas.scale(options.scale, options.scale) canvas.translate(-bounds.minX, -bounds.minY) + }) + if (!png) return null - renderer.renderSceneToCanvas(canvas, graph, pageId) - - surface.flush() - const image = surface.makeImageSnapshot() - const quality = options.quality ?? defaultQuality(options.format) - const fmt = ckImageFormat(ck, options.format) - const encoded = image.encodeToBytes(fmt, quality) - image.delete() - if (!encoded) { - console.error(`encodeToBytes returned null for format=${options.format} quality=${quality}`) - } - return encoded ? new Uint8Array(encoded) : null - } finally { - surface.delete() - } + const quality = options.quality ?? (options.format === 'PNG' ? 100 : 90) + return reencodeImage(png, options.format, quality) } export function renderThumbnail( @@ -118,26 +147,12 @@ export function renderThumbnail( if (contentW <= 0 || contentH <= 0) return null const scale = Math.min(width / contentW, height / contentH, 2) - const surface = ck.MakeSurface(width, height) - if (!surface) return null - try { - const canvas = surface.getCanvas() + return renderToSurface(ck, renderer, graph, pageId, width, height, (canvas) => { canvas.clear(ck.Color4f(renderer.pageColor.r, renderer.pageColor.g, renderer.pageColor.b, 1)) - const offsetX = (width - contentW * scale) / 2 - bounds.minX * scale const offsetY = (height - contentH * scale) / 2 - bounds.minY * scale canvas.translate(offsetX, offsetY) canvas.scale(scale, scale) - - renderer.renderSceneToCanvas(canvas, graph, pageId) - - surface.flush() - const image = surface.makeImageSnapshot() - const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 90) - image.delete() - return encoded ? new Uint8Array(encoded) : null - } finally { - surface.delete() - } + }) } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 76947a513..06d08e3f2 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -583,11 +583,11 @@ export function createEditorStore() { } } - function renderExportImage( + async function renderExportImage( nodeIds: string[], scale: number, format: ExportFormat - ): Uint8Array | null { + ): Promise { if (!_ck || !_renderer) return null const ids = nodeIds.length > 0 ? nodeIds : graph.getChildren(state.currentPageId).map((n) => n.id) @@ -619,9 +619,11 @@ export function createEditorStore() { async function exportSelection(scale: number, format: ExportFormat) { const ids = [...state.selectedIds] - const data = renderExportImage(ids, scale, format) + const data = await renderExportImage(ids, scale, format) if (!data) { - console.error(`Export failed: renderExportImage returned null for format=${format} scale=${scale}`) + console.error( + `Export failed: renderExportImage returned null for format=${format} scale=${scale}` + ) return } diff --git a/vite.config.ts b/vite.config.ts index 07ce3c8b1..c82a9923c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig(async () => ({ { name: 'copy-canvaskit-wasm', buildStart() { - const src = 'node_modules/canvaskit-wasm/bin/full/canvaskit.wasm' + const src = 'node_modules/canvaskit-wasm/bin/canvaskit.wasm' const dest = 'public/canvaskit.wasm' if (existsSync(src) && !existsSync(dest)) { copyFileSync(src, dest)