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().
This commit is contained in:
Danila Poyarkov 2026-03-01 00:11:48 +03:00
parent 7d4b821e77
commit 103c5617cf
4 changed files with 80 additions and 63 deletions

View file

@ -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' }))
}

View file

@ -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<Uint8Array> {
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<Uint8Array | null> {
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()
}
})
}

View file

@ -583,11 +583,11 @@ export function createEditorStore() {
}
}
function renderExportImage(
async function renderExportImage(
nodeIds: string[],
scale: number,
format: ExportFormat
): Uint8Array | null {
): Promise<Uint8Array | null> {
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
}

View file

@ -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)