tokimeki-image-editor 0.4.9 → 0.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -220,41 +220,25 @@ export async function applyTransformWithWebGPU(img, transform, adjustments, crop
220
220
  const { exportWithWebGPU } = await import('./webgpu-render');
221
221
  const webgpuCanvas = await exportWithWebGPU(img, adjustments, transform, cropArea, blurAreas);
222
222
  if (webgpuCanvas) {
223
- // Copy WebGPU result to a Canvas2D canvas.
224
- // Calling toBlob directly on a WebGPU canvas triggers expensive browser
225
- // compositor layerization in complex DOM trees. Canvas2D avoids this.
226
- // Use OffscreenCanvas for the initial blit to bypass the compositor entirely.
227
- const w = webgpuCanvas.width;
228
- const h = webgpuCanvas.height;
229
- let finalCanvas;
230
- if (typeof OffscreenCanvas !== 'undefined') {
231
- // OffscreenCanvas path: completely avoids DOM compositor
232
- const offscreen = new OffscreenCanvas(w, h);
233
- const offCtx = offscreen.getContext('2d');
234
- offCtx.drawImage(webgpuCanvas, 0, 0);
235
- // Transfer to a regular canvas for stamps/annotations (which need DOM canvas APIs)
236
- finalCanvas = document.createElement('canvas');
237
- finalCanvas.width = w;
238
- finalCanvas.height = h;
223
+ // Apply stamps and annotations on top (WebGPU doesn't handle these yet)
224
+ if (stampAreas.length > 0 || annotations.length > 0) {
225
+ const finalCanvas = document.createElement('canvas');
226
+ finalCanvas.width = webgpuCanvas.width;
227
+ finalCanvas.height = webgpuCanvas.height;
239
228
  const ctx = finalCanvas.getContext('2d');
240
- ctx.drawImage(offscreen, 0, 0);
241
- }
242
- else {
243
- finalCanvas = document.createElement('canvas');
244
- finalCanvas.width = w;
245
- finalCanvas.height = h;
246
- const ctx = finalCanvas.getContext('2d');
247
- ctx.drawImage(webgpuCanvas, 0, 0);
248
- }
249
- // Apply stamps and annotations on top
250
- const exportViewport = { zoom: 1, offsetX: 0, offsetY: 0, scale: 1 };
251
- if (annotations.length > 0) {
252
- applyAnnotations(finalCanvas, img, exportViewport, annotations, cropArea);
253
- }
254
- if (stampAreas.length > 0) {
255
- applyStamps(finalCanvas, img, exportViewport, stampAreas, cropArea);
229
+ if (ctx) {
230
+ ctx.drawImage(webgpuCanvas, 0, 0);
231
+ const exportViewport = { zoom: 1, offsetX: 0, offsetY: 0, scale: 1 };
232
+ if (annotations.length > 0) {
233
+ applyAnnotations(finalCanvas, img, exportViewport, annotations, cropArea);
234
+ }
235
+ if (stampAreas.length > 0) {
236
+ applyStamps(finalCanvas, img, exportViewport, stampAreas, cropArea);
237
+ }
238
+ return finalCanvas;
239
+ }
256
240
  }
257
- return finalCanvas;
241
+ return webgpuCanvas;
258
242
  }
259
243
  }
260
244
  catch (error) {
@@ -344,6 +344,31 @@ export function applyKeyboardAction(state, action) {
344
344
  return state;
345
345
  }
346
346
  }
347
+ /**
348
+ * Encode pixel data to Blob in a Web Worker.
349
+ * Completely isolated from main thread GPU/compositor — avoids the browser
350
+ * layerization bottleneck that makes toBlob extremely slow on complex pages.
351
+ */
352
+ function encodeInWorker(imageData, format, quality) {
353
+ return new Promise((resolve, reject) => {
354
+ const code = `self.onmessage=async e=>{const{pixels,w,h,fmt,q}=e.data;const c=new OffscreenCanvas(w,h);c.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(pixels),w,h),0,0);const b=await c.convertToBlob({type:fmt,quality:q});self.postMessage(b)}`;
355
+ const url = URL.createObjectURL(new Blob([code], { type: 'application/javascript' }));
356
+ const worker = new Worker(url);
357
+ worker.onmessage = (e) => {
358
+ resolve(e.data);
359
+ worker.terminate();
360
+ URL.revokeObjectURL(url);
361
+ };
362
+ worker.onerror = (e) => {
363
+ reject(new Error('Worker encode failed: ' + e.message));
364
+ worker.terminate();
365
+ URL.revokeObjectURL(url);
366
+ };
367
+ // Transfer the ArrayBuffer (zero-copy)
368
+ const buffer = imageData.data.buffer;
369
+ worker.postMessage({ pixels: buffer, w: imageData.width, h: imageData.height, fmt: format, q: quality }, [buffer]);
370
+ });
371
+ }
347
372
  /**
348
373
  * Export editor state to image
349
374
  */
@@ -359,24 +384,34 @@ export async function exportImage(state) {
359
384
  if (_DEV)
360
385
  console.timeEnd('[export] 1. WebGPU render');
361
386
  const format = state.exportOptions.format === 'png' ? 'image/png' : 'image/jpeg';
387
+ const w = exportCanvas.width;
388
+ const h = exportCanvas.height;
389
+ // Extract pixel data on main thread
362
390
  if (_DEV)
363
- console.time('[export] 2. toBlob');
364
- const blob = await new Promise((resolve) => {
365
- exportCanvas.toBlob(b => resolve(b), format, state.exportOptions.quality);
366
- });
391
+ console.time('[export] 2. getImageData');
392
+ const ctx = exportCanvas.getContext('2d');
393
+ const imageData = ctx
394
+ ? ctx.getImageData(0, 0, w, h)
395
+ : new ImageData(w, h);
396
+ if (_DEV)
397
+ console.timeEnd('[export] 2. getImageData');
398
+ // Encode in a Web Worker to completely isolate from main thread GPU/compositor
399
+ if (_DEV)
400
+ console.time('[export] 3. worker encode');
401
+ const blob = await encodeInWorker(imageData, format, state.exportOptions.quality);
367
402
  if (_DEV) {
368
- console.timeEnd('[export] 2. toBlob');
403
+ console.timeEnd('[export] 3. worker encode');
369
404
  console.log('[export] blob:', (blob.size / 1024 / 1024).toFixed(1) + 'MB');
370
405
  }
371
406
  if (_DEV)
372
- console.time('[export] 3. readAsDataURL');
407
+ console.time('[export] 4. readAsDataURL');
373
408
  const dataUrl = await new Promise((resolve) => {
374
409
  const reader = new FileReader();
375
410
  reader.onload = () => resolve(reader.result);
376
411
  reader.readAsDataURL(blob);
377
412
  });
378
413
  if (_DEV) {
379
- console.timeEnd('[export] 3. readAsDataURL');
414
+ console.timeEnd('[export] 4. readAsDataURL');
380
415
  console.timeEnd('[export] total');
381
416
  }
382
417
  // Dimensions directly from the export canvas (already crop+rotation adjusted)
@@ -1068,7 +1068,10 @@ export async function exportWithWebGPU(imageSource, adjustments, transform, crop
1068
1068
  console.warn('Failed to get WebGPU context for export');
1069
1069
  return null;
1070
1070
  }
1071
- context.configure({ device, format, alphaMode: 'premultiplied' });
1071
+ context.configure({
1072
+ device, format, alphaMode: 'premultiplied',
1073
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
1074
+ });
1072
1075
  if (_DEV)
1073
1076
  console.time('[exportGPU] a. createImageBitmap');
1074
1077
  const bitmap = imageSource instanceof ImageBitmap
@@ -1386,18 +1389,63 @@ export async function exportWithWebGPU(imageSource, adjustments, transform, crop
1386
1389
  { binding: 2, resource: { buffer: grainUniformBuffer } },
1387
1390
  ],
1388
1391
  });
1392
+ // Render final pass to canvas AND copy pixels to CPU buffer in the same command
1393
+ const canvasTexture = context.getCurrentTexture();
1389
1394
  const finalEncoder = device.createCommandEncoder();
1390
- const canvasView = context.getCurrentTexture().createView();
1391
- createRenderPass(finalEncoder, canvasView, grainPipeline, grainBindGroup);
1395
+ createRenderPass(finalEncoder, canvasTexture.createView(), grainPipeline, grainBindGroup);
1396
+ // Copy rendered pixels directly from GPU texture to a CPU-readable buffer.
1397
+ // This bypasses browser compositor layerization that makes drawImage/toBlob
1398
+ // extremely slow on pages with many compositing layers.
1399
+ const bytesPerRow = Math.ceil(outputWidth * 4 / 256) * 256; // 256-byte alignment required by WebGPU
1400
+ const readBuffer = device.createBuffer({
1401
+ size: bytesPerRow * outputHeight,
1402
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
1403
+ });
1404
+ finalEncoder.copyTextureToBuffer({ texture: canvasTexture }, { buffer: readBuffer, bytesPerRow }, [outputWidth, outputHeight]);
1392
1405
  device.queue.submit([finalEncoder.finish()]);
1393
1406
  if (_DEV)
1394
1407
  console.timeEnd('[exportGPU] b. render passes');
1395
1408
  if (_DEV)
1396
- console.time('[exportGPU] c. onSubmittedWorkDone');
1397
- await device.queue.onSubmittedWorkDone();
1409
+ console.time('[exportGPU] c. GPU readback');
1410
+ await readBuffer.mapAsync(GPUMapMode.READ);
1411
+ const gpuData = new Uint8Array(readBuffer.getMappedRange());
1412
+ // Build a pure Canvas2D from the GPU pixel data.
1413
+ // toBlob on this canvas won't trigger compositor layerization.
1414
+ const resultCanvas = document.createElement('canvas');
1415
+ resultCanvas.width = outputWidth;
1416
+ resultCanvas.height = outputHeight;
1417
+ const ctx2d = resultCanvas.getContext('2d');
1418
+ const imageData = ctx2d.createImageData(outputWidth, outputHeight);
1419
+ const dst = new Uint32Array(imageData.data.buffer);
1420
+ const isBGRA = format === 'bgra8unorm';
1421
+ const rowPixels = bytesPerRow / 4;
1422
+ if (isBGRA) {
1423
+ // Swap R↔B channels: BGRA → RGBA
1424
+ const src = new Uint32Array(gpuData.buffer, gpuData.byteOffset, gpuData.byteLength / 4);
1425
+ for (let y = 0; y < outputHeight; y++) {
1426
+ const srcRow = y * rowPixels;
1427
+ const dstRow = y * outputWidth;
1428
+ for (let x = 0; x < outputWidth; x++) {
1429
+ const p = src[srcRow + x];
1430
+ dst[dstRow + x] = (p & 0xFF00FF00) | ((p & 0xFF) << 16) | ((p >> 16) & 0xFF);
1431
+ }
1432
+ }
1433
+ }
1434
+ else {
1435
+ // RGBA — just copy rows (skip padding)
1436
+ const src = new Uint8Array(gpuData.buffer, gpuData.byteOffset, gpuData.byteLength);
1437
+ const dstBytes = imageData.data;
1438
+ const rowBytes = outputWidth * 4;
1439
+ for (let y = 0; y < outputHeight; y++) {
1440
+ dstBytes.set(src.subarray(y * bytesPerRow, y * bytesPerRow + rowBytes), y * rowBytes);
1441
+ }
1442
+ }
1443
+ ctx2d.putImageData(imageData, 0, 0);
1444
+ readBuffer.unmap();
1398
1445
  if (_DEV)
1399
- console.timeEnd('[exportGPU] c. onSubmittedWorkDone');
1400
- // Cleanup per-export resources (pipelines/sampler are cached)
1446
+ console.timeEnd('[exportGPU] c. GPU readback');
1447
+ // Cleanup
1448
+ readBuffer.destroy();
1401
1449
  texture.destroy();
1402
1450
  intermediate1.destroy();
1403
1451
  intermediate2?.destroy();
@@ -1411,7 +1459,7 @@ export async function exportWithWebGPU(imageSource, adjustments, transform, crop
1411
1459
  curveLUTTexture.destroy();
1412
1460
  if (_DEV)
1413
1461
  console.timeEnd('[exportGPU] total');
1414
- return canvas;
1462
+ return resultCanvas;
1415
1463
  }
1416
1464
  catch (error) {
1417
1465
  console.error('Failed to export with WebGPU:', error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokimeki-image-editor",
3
- "version": "0.4.9",
3
+ "version": "0.4.12",
4
4
  "description": "A image editor for svelte.",
5
5
  "type": "module",
6
6
  "license": "MIT",