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.
- package/dist/utils/canvas.js +17 -33
- package/dist/utils/editor-core.js +42 -7
- package/dist/utils/webgpu-render.js +56 -8
- package/package.json +1 -1
package/dist/utils/canvas.js
CHANGED
|
@@ -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
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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.
|
|
364
|
-
const
|
|
365
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
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({
|
|
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
|
-
|
|
1391
|
-
|
|
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.
|
|
1397
|
-
await
|
|
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.
|
|
1400
|
-
// Cleanup
|
|
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
|
|
1462
|
+
return resultCanvas;
|
|
1415
1463
|
}
|
|
1416
1464
|
catch (error) {
|
|
1417
1465
|
console.error('Failed to export with WebGPU:', error);
|