tokimeki-image-editor 0.1.7 → 0.1.9

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.
@@ -0,0 +1,1192 @@
1
+ import SHADER_CODE from '../shaders/image-editor.wgsl?raw';
2
+ import BLUR_SHADER_CODE from '../shaders/blur.wgsl?raw';
3
+ import COMPOSITE_SHADER_CODE from '../shaders/composite.wgsl?raw';
4
+ import GRAIN_SHADER_CODE from '../shaders/grain.wgsl?raw';
5
+ /**
6
+ * WebGPU Render Pipeline for image adjustments with viewport and transform support
7
+ * Uses fragment shader to apply adjustments in real-time
8
+ */
9
+ // WebGPU state
10
+ let gpuDevice = null;
11
+ let gpuContext = null;
12
+ let gpuPipeline = null;
13
+ let gpuUniformBuffer = null;
14
+ let gpuSampler = null;
15
+ let gpuTexture = null;
16
+ let gpuBindGroup = null;
17
+ // Blur pipeline state
18
+ let gpuBlurPipeline = null;
19
+ let gpuBlurUniformBuffer = null;
20
+ let gpuIntermediateTexture = null;
21
+ let gpuIntermediateTexture2 = null;
22
+ // Composite pipeline state
23
+ let gpuCompositePipeline = null;
24
+ let gpuCompositeUniformBuffer = null;
25
+ let gpuIntermediateTexture3 = null;
26
+ let gpuIntermediateTexture4 = null; // 4th texture for blur temp
27
+ // Grain pipeline state
28
+ let gpuGrainPipeline = null;
29
+ let gpuGrainUniformBuffer = null;
30
+ // Helper functions and constants
31
+ const BLUR_UNIFORMS_ZERO = new Float32Array([1.0, 0.0, 0.0, 0.0]);
32
+ function clamp(value, min, max) {
33
+ return Math.max(min, Math.min(max, value));
34
+ }
35
+ function createRenderPass(commandEncoder, view, pipeline, bindGroup) {
36
+ const renderPass = commandEncoder.beginRenderPass({
37
+ colorAttachments: [{
38
+ view,
39
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
40
+ loadOp: 'clear',
41
+ storeOp: 'store',
42
+ }],
43
+ });
44
+ renderPass.setPipeline(pipeline);
45
+ renderPass.setBindGroup(0, bindGroup);
46
+ renderPass.draw(3, 1, 0, 0);
47
+ renderPass.end();
48
+ }
49
+ function createBlurBindGroup(textureView) {
50
+ if (!gpuDevice || !gpuBlurPipeline || !gpuSampler || !gpuBlurUniformBuffer) {
51
+ return null;
52
+ }
53
+ return gpuDevice.createBindGroup({
54
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
55
+ entries: [
56
+ { binding: 0, resource: gpuSampler },
57
+ { binding: 1, resource: textureView },
58
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
59
+ ],
60
+ });
61
+ }
62
+ /**
63
+ * Initialize WebGPU for the given canvas
64
+ */
65
+ export async function initWebGPUCanvas(canvas) {
66
+ try {
67
+ if (!navigator.gpu) {
68
+ console.warn('WebGPU not supported');
69
+ return false;
70
+ }
71
+ const adapter = await navigator.gpu.requestAdapter();
72
+ if (!adapter) {
73
+ console.warn('No WebGPU adapter');
74
+ return false;
75
+ }
76
+ gpuDevice = await adapter.requestDevice();
77
+ // Get WebGPU context
78
+ gpuContext = canvas.getContext('webgpu');
79
+ if (!gpuContext) {
80
+ console.warn('Failed to get WebGPU context');
81
+ return false;
82
+ }
83
+ const format = navigator.gpu.getPreferredCanvasFormat();
84
+ gpuContext.configure({
85
+ device: gpuDevice,
86
+ format: format,
87
+ alphaMode: 'premultiplied',
88
+ });
89
+ // Create uniform buffer
90
+ // 10 (adjustments) + 4 (viewport) + 4 (transform) + 2 (canvas dims) + 2 (image dims) + 4 (crop) = 26 floats
91
+ // Round up to 32 for alignment
92
+ gpuUniformBuffer = gpuDevice.createBuffer({
93
+ size: 128, // 32 floats * 4 bytes
94
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
95
+ });
96
+ // Create sampler
97
+ gpuSampler = gpuDevice.createSampler({
98
+ magFilter: 'linear',
99
+ minFilter: 'linear',
100
+ });
101
+ // Create shader module
102
+ const shaderModule = gpuDevice.createShaderModule({ code: SHADER_CODE });
103
+ // Create render pipeline
104
+ gpuPipeline = gpuDevice.createRenderPipeline({
105
+ layout: 'auto',
106
+ vertex: {
107
+ module: shaderModule,
108
+ entryPoint: 'vs_main',
109
+ },
110
+ fragment: {
111
+ module: shaderModule,
112
+ entryPoint: 'fs_main',
113
+ targets: [{ format: format }],
114
+ },
115
+ primitive: {
116
+ topology: 'triangle-list',
117
+ },
118
+ });
119
+ // Create blur pipeline
120
+ const blurShaderModule = gpuDevice.createShaderModule({ code: BLUR_SHADER_CODE });
121
+ gpuBlurPipeline = gpuDevice.createRenderPipeline({
122
+ layout: 'auto',
123
+ vertex: {
124
+ module: blurShaderModule,
125
+ entryPoint: 'vs_main',
126
+ },
127
+ fragment: {
128
+ module: blurShaderModule,
129
+ entryPoint: 'fs_main',
130
+ targets: [{ format: format }],
131
+ },
132
+ primitive: {
133
+ topology: 'triangle-list',
134
+ },
135
+ });
136
+ // Create blur uniform buffer
137
+ gpuBlurUniformBuffer = gpuDevice.createBuffer({
138
+ size: 16, // 2 floats (direction) + 1 float (radius) + 1 float (padding) = 4 floats * 4 bytes
139
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
140
+ });
141
+ // Create composite pipeline
142
+ const compositeShaderModule = gpuDevice.createShaderModule({ code: COMPOSITE_SHADER_CODE });
143
+ gpuCompositePipeline = gpuDevice.createRenderPipeline({
144
+ layout: 'auto',
145
+ vertex: {
146
+ module: compositeShaderModule,
147
+ entryPoint: 'vs_main',
148
+ },
149
+ fragment: {
150
+ module: compositeShaderModule,
151
+ entryPoint: 'fs_main',
152
+ targets: [{ format: format }],
153
+ },
154
+ primitive: {
155
+ topology: 'triangle-list',
156
+ },
157
+ });
158
+ // Create composite uniform buffer
159
+ gpuCompositeUniformBuffer = gpuDevice.createBuffer({
160
+ size: 16, // 4 floats (minX, minY, maxX, maxY) = 4 floats * 4 bytes
161
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
162
+ });
163
+ // Create grain pipeline
164
+ const grainShaderModule = gpuDevice.createShaderModule({ code: GRAIN_SHADER_CODE });
165
+ gpuGrainPipeline = gpuDevice.createRenderPipeline({
166
+ layout: 'auto',
167
+ vertex: {
168
+ module: grainShaderModule,
169
+ entryPoint: 'vs_main',
170
+ },
171
+ fragment: {
172
+ module: grainShaderModule,
173
+ entryPoint: 'fs_main',
174
+ targets: [{ format: format }],
175
+ },
176
+ primitive: {
177
+ topology: 'triangle-list',
178
+ },
179
+ });
180
+ // Create grain uniform buffer
181
+ // 1 (grain) + 4 (viewport) + 4 (transform) + 2 (canvas) + 2 (image) + 4 (crop) = 17 floats
182
+ // Padded to 20 floats for alignment (80 bytes)
183
+ gpuGrainUniformBuffer = gpuDevice.createBuffer({
184
+ size: 80, // 20 floats * 4 bytes
185
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
186
+ });
187
+ console.log('WebGPU render pipeline initialized successfully');
188
+ return true;
189
+ }
190
+ catch (error) {
191
+ console.error('Failed to initialize WebGPU:', error);
192
+ return false;
193
+ }
194
+ }
195
+ /**
196
+ * Upload image to GPU as texture
197
+ */
198
+ export async function uploadImageToGPU(imageSource) {
199
+ if (!gpuDevice || !gpuPipeline)
200
+ return false;
201
+ try {
202
+ // Create texture from image
203
+ const bitmap = imageSource instanceof ImageBitmap
204
+ ? imageSource
205
+ : await createImageBitmap(imageSource);
206
+ gpuTexture = gpuDevice.createTexture({
207
+ size: [bitmap.width, bitmap.height, 1],
208
+ format: 'rgba8unorm',
209
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
210
+ });
211
+ gpuDevice.queue.copyExternalImageToTexture({ source: bitmap }, { texture: gpuTexture }, [bitmap.width, bitmap.height]);
212
+ // Update bind group
213
+ updateBindGroup();
214
+ return true;
215
+ }
216
+ catch (error) {
217
+ console.error('Failed to upload image to GPU:', error);
218
+ return false;
219
+ }
220
+ }
221
+ /**
222
+ * Update bind group with current texture and uniforms
223
+ */
224
+ function updateBindGroup() {
225
+ if (!gpuDevice || !gpuPipeline || !gpuTexture || !gpuSampler || !gpuUniformBuffer)
226
+ return;
227
+ gpuBindGroup = gpuDevice.createBindGroup({
228
+ layout: gpuPipeline.getBindGroupLayout(0),
229
+ entries: [
230
+ { binding: 0, resource: gpuSampler },
231
+ { binding: 1, resource: gpuTexture.createView() },
232
+ { binding: 2, resource: { buffer: gpuUniformBuffer } },
233
+ ],
234
+ });
235
+ }
236
+ /**
237
+ * Ensure intermediate textures exist and match canvas size
238
+ */
239
+ function ensureIntermediateTextures(width, height) {
240
+ if (!gpuDevice)
241
+ return;
242
+ const format = navigator.gpu.getPreferredCanvasFormat();
243
+ // Check if textures need to be recreated
244
+ const needsRecreate = !gpuIntermediateTexture ||
245
+ gpuIntermediateTexture.width !== width ||
246
+ gpuIntermediateTexture.height !== height;
247
+ if (needsRecreate) {
248
+ // Destroy old textures
249
+ gpuIntermediateTexture?.destroy();
250
+ gpuIntermediateTexture2?.destroy();
251
+ gpuIntermediateTexture3?.destroy();
252
+ gpuIntermediateTexture4?.destroy();
253
+ // Create new intermediate textures
254
+ gpuIntermediateTexture = gpuDevice.createTexture({
255
+ size: [width, height, 1],
256
+ format: format,
257
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
258
+ });
259
+ gpuIntermediateTexture2 = gpuDevice.createTexture({
260
+ size: [width, height, 1],
261
+ format: format,
262
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
263
+ });
264
+ gpuIntermediateTexture3 = gpuDevice.createTexture({
265
+ size: [width, height, 1],
266
+ format: format,
267
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
268
+ });
269
+ gpuIntermediateTexture4 = gpuDevice.createTexture({
270
+ size: [width, height, 1],
271
+ format: format,
272
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
273
+ });
274
+ }
275
+ }
276
+ /**
277
+ * Render with adjustments, viewport, transform, crop, and blur areas
278
+ */
279
+ export function renderWithAdjustments(adjustments, viewport, transform, canvasWidth, canvasHeight, imageWidth, imageHeight, cropArea = null, blurAreas = []) {
280
+ if (!gpuDevice || !gpuContext || !gpuPipeline || !gpuBindGroup || !gpuUniformBuffer) {
281
+ return false;
282
+ }
283
+ try {
284
+ // Convert rotation from degrees to radians
285
+ const rotationRad = (transform.rotation * Math.PI) / 180;
286
+ // Check if we need multi-pass rendering (for blur or grain layering)
287
+ const hasGlobalBlur = adjustments.blur > 0;
288
+ const hasRegionalBlur = blurAreas.length > 0 && blurAreas.some(area => area.blurStrength > 0);
289
+ const hasGrain = adjustments.grain > 0;
290
+ const needsMultiPass = hasGlobalBlur || hasRegionalBlur || hasGrain;
291
+ // If grain is enabled, we apply it in a separate pass AFTER blur
292
+ // So pass grain=0 to the main shader
293
+ const mainShaderGrain = needsMultiPass ? 0 : adjustments.grain;
294
+ // Update uniforms
295
+ const uniformData = new Float32Array([
296
+ // Adjustments (11 floats)
297
+ adjustments.brightness,
298
+ adjustments.contrast,
299
+ adjustments.exposure,
300
+ adjustments.highlights,
301
+ adjustments.shadows,
302
+ adjustments.saturation,
303
+ adjustments.temperature,
304
+ adjustments.sepia,
305
+ adjustments.grayscale,
306
+ adjustments.vignette,
307
+ mainShaderGrain,
308
+ // Viewport (4 floats)
309
+ viewport.zoom,
310
+ viewport.offsetX,
311
+ viewport.offsetY,
312
+ viewport.scale,
313
+ // Transform (4 floats)
314
+ rotationRad,
315
+ transform.flipHorizontal ? -1.0 : 1.0,
316
+ transform.flipVertical ? -1.0 : 1.0,
317
+ transform.scale,
318
+ // Canvas dimensions (2 floats)
319
+ canvasWidth,
320
+ canvasHeight,
321
+ // Image dimensions (2 floats)
322
+ imageWidth,
323
+ imageHeight,
324
+ // Crop area (4 floats)
325
+ cropArea?.x ?? 0,
326
+ cropArea?.y ?? 0,
327
+ cropArea?.width ?? 0,
328
+ cropArea?.height ?? 0,
329
+ // Padding to 32 floats for alignment (11+4+4+2+2+4=27, need 5 padding)
330
+ 0, 0, 0, 0, 0
331
+ ]);
332
+ gpuDevice.queue.writeBuffer(gpuUniformBuffer, 0, uniformData);
333
+ if (!needsMultiPass) {
334
+ // No blur or grain - render directly to canvas
335
+ const commandEncoder = gpuDevice.createCommandEncoder();
336
+ const textureView = gpuContext.getCurrentTexture().createView();
337
+ const renderPass = commandEncoder.beginRenderPass({
338
+ colorAttachments: [{
339
+ view: textureView,
340
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
341
+ loadOp: 'clear',
342
+ storeOp: 'store',
343
+ }],
344
+ });
345
+ renderPass.setPipeline(gpuPipeline);
346
+ renderPass.setBindGroup(0, gpuBindGroup);
347
+ renderPass.draw(3, 1, 0, 0);
348
+ renderPass.end();
349
+ gpuDevice.queue.submit([commandEncoder.finish()]);
350
+ return true;
351
+ }
352
+ // Has blur or grain - use multi-pass rendering
353
+ return renderWithBlur(blurAreas, canvasWidth, canvasHeight, imageWidth, imageHeight, cropArea, viewport, transform, adjustments.blur, adjustments.grain);
354
+ }
355
+ catch (error) {
356
+ console.error('WebGPU render failed:', error);
357
+ return false;
358
+ }
359
+ }
360
+ /**
361
+ * Render with blur (global and/or regional) and grain using multi-pass compositing
362
+ */
363
+ function renderWithBlur(blurAreas, canvasWidth, canvasHeight, imageWidth, imageHeight, cropArea, viewport, transform, globalBlurStrength = 0, grainAmount = 0) {
364
+ if (!gpuDevice || !gpuContext || !gpuPipeline || !gpuBindGroup ||
365
+ !gpuBlurPipeline || !gpuBlurUniformBuffer ||
366
+ !gpuCompositePipeline || !gpuCompositeUniformBuffer ||
367
+ !gpuGrainPipeline || !gpuGrainUniformBuffer || !gpuSampler) {
368
+ console.error('Missing WebGPU resources for multi-pass rendering');
369
+ return false;
370
+ }
371
+ // Ensure all intermediate textures exist
372
+ ensureIntermediateTextures(canvasWidth, canvasHeight);
373
+ if (!gpuIntermediateTexture || !gpuIntermediateTexture2 ||
374
+ !gpuIntermediateTexture3 || !gpuIntermediateTexture4) {
375
+ console.error('Failed to create intermediate textures');
376
+ return false;
377
+ }
378
+ const intermediateView1 = gpuIntermediateTexture.createView();
379
+ const intermediateView2 = gpuIntermediateTexture2.createView();
380
+ const intermediateView3 = gpuIntermediateTexture3.createView();
381
+ const intermediateView4 = gpuIntermediateTexture4.createView();
382
+ // === Pass 1: Render adjustments to intermediate texture 1 (base image) ===
383
+ let commandEncoder = gpuDevice.createCommandEncoder();
384
+ const renderPass1 = commandEncoder.beginRenderPass({
385
+ colorAttachments: [{
386
+ view: intermediateView1,
387
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
388
+ loadOp: 'clear',
389
+ storeOp: 'store',
390
+ }],
391
+ });
392
+ renderPass1.setPipeline(gpuPipeline);
393
+ renderPass1.setBindGroup(0, gpuBindGroup);
394
+ renderPass1.draw(3, 1, 0, 0);
395
+ renderPass1.end();
396
+ gpuDevice.queue.submit([commandEncoder.finish()]);
397
+ // === Pass 2: Copy base image to accumulator (intermediate1 -> intermediate4) ===
398
+ commandEncoder = gpuDevice.createCommandEncoder();
399
+ gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
400
+ const copyBindGroup = gpuDevice.createBindGroup({
401
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
402
+ entries: [
403
+ { binding: 0, resource: gpuSampler },
404
+ { binding: 1, resource: intermediateView1 },
405
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
406
+ ],
407
+ });
408
+ const copyPass = commandEncoder.beginRenderPass({
409
+ colorAttachments: [{
410
+ view: intermediateView4,
411
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
412
+ loadOp: 'clear',
413
+ storeOp: 'store',
414
+ }],
415
+ });
416
+ copyPass.setPipeline(gpuBlurPipeline);
417
+ copyPass.setBindGroup(0, copyBindGroup);
418
+ copyPass.draw(3, 1, 0, 0);
419
+ copyPass.end();
420
+ gpuDevice.queue.submit([commandEncoder.finish()]);
421
+ // === Pass 3a: Apply global blur if enabled ===
422
+ if (globalBlurStrength > 0) {
423
+ // Map blur 0-100 to radius 0-10
424
+ const globalBlurRadius = Math.ceil((globalBlurStrength / 100) * 10);
425
+ // Horizontal blur pass (intermediate4 accumulator -> intermediate2)
426
+ commandEncoder = gpuDevice.createCommandEncoder();
427
+ const blurUniformsH = new Float32Array([1.0, 0.0, globalBlurRadius, 0.0]);
428
+ gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsH);
429
+ const blurBindGroupH = gpuDevice.createBindGroup({
430
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
431
+ entries: [
432
+ { binding: 0, resource: gpuSampler },
433
+ { binding: 1, resource: intermediateView4 },
434
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
435
+ ],
436
+ });
437
+ const renderPassH = commandEncoder.beginRenderPass({
438
+ colorAttachments: [{
439
+ view: intermediateView2,
440
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
441
+ loadOp: 'clear',
442
+ storeOp: 'store',
443
+ }],
444
+ });
445
+ renderPassH.setPipeline(gpuBlurPipeline);
446
+ renderPassH.setBindGroup(0, blurBindGroupH);
447
+ renderPassH.draw(3, 1, 0, 0);
448
+ renderPassH.end();
449
+ gpuDevice.queue.submit([commandEncoder.finish()]);
450
+ // Vertical blur pass (intermediate2 -> intermediate4)
451
+ commandEncoder = gpuDevice.createCommandEncoder();
452
+ const blurUniformsV = new Float32Array([0.0, 1.0, globalBlurRadius, 0.0]);
453
+ gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsV);
454
+ const blurBindGroupV = gpuDevice.createBindGroup({
455
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
456
+ entries: [
457
+ { binding: 0, resource: gpuSampler },
458
+ { binding: 1, resource: intermediateView2 },
459
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
460
+ ],
461
+ });
462
+ const renderPassV = commandEncoder.beginRenderPass({
463
+ colorAttachments: [{
464
+ view: intermediateView4,
465
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
466
+ loadOp: 'clear',
467
+ storeOp: 'store',
468
+ }],
469
+ });
470
+ renderPassV.setPipeline(gpuBlurPipeline);
471
+ renderPassV.setBindGroup(0, blurBindGroupV);
472
+ renderPassV.draw(3, 1, 0, 0);
473
+ renderPassV.end();
474
+ gpuDevice.queue.submit([commandEncoder.finish()]);
475
+ }
476
+ // === Pass 3b: Process each regional blur area ===
477
+ // Determine source dimensions based on crop
478
+ const sourceWidth = cropArea ? cropArea.width : imageWidth;
479
+ const sourceHeight = cropArea ? cropArea.height : imageHeight;
480
+ const cropOffsetX = cropArea ? cropArea.x : 0;
481
+ const cropOffsetY = cropArea ? cropArea.y : 0;
482
+ // Calculate transform parameters (same as 2D canvas)
483
+ const totalScale = viewport.scale * viewport.zoom * transform.scale;
484
+ const centerX = canvasWidth / 2;
485
+ const centerY = canvasHeight / 2;
486
+ for (const blurArea of blurAreas) {
487
+ if (blurArea.blurStrength <= 0)
488
+ continue;
489
+ // Calculate blur radius to match Canvas2D behavior
490
+ const imageBlurPx = (blurArea.blurStrength / 100) * 100;
491
+ const blurRadius = Math.ceil(imageBlurPx * totalScale);
492
+ // Blur areas are in IMAGE space, need to convert to canvas space, then to UV
493
+ // Following the same logic as 2D canvas implementation
494
+ // 1. Convert blur area to crop-relative coordinates
495
+ const relativeX = blurArea.x - cropOffsetX;
496
+ const relativeY = blurArea.y - cropOffsetY;
497
+ // 2. Transform from image space to canvas space
498
+ const canvasBlurX = (relativeX - sourceWidth / 2) * totalScale + centerX + viewport.offsetX;
499
+ const canvasBlurY = (relativeY - sourceHeight / 2) * totalScale + centerY + viewport.offsetY;
500
+ const canvasBlurWidth = blurArea.width * totalScale;
501
+ const canvasBlurHeight = blurArea.height * totalScale;
502
+ // 3. Convert canvas space to normalized UV coordinates (0-1)
503
+ const minX = clamp(canvasBlurX / canvasWidth, 0, 1);
504
+ const minY = clamp(canvasBlurY / canvasHeight, 0, 1);
505
+ const maxX = clamp((canvasBlurX + canvasBlurWidth) / canvasWidth, 0, 1);
506
+ const maxY = clamp((canvasBlurY + canvasBlurHeight) / canvasHeight, 0, 1);
507
+ // Pass 3a: Horizontal blur (intermediate1 base -> intermediate2)
508
+ commandEncoder = gpuDevice.createCommandEncoder();
509
+ const blurUniformsH = new Float32Array([1.0, 0.0, blurRadius, 0.0]);
510
+ gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsH);
511
+ const blurBindGroupH = gpuDevice.createBindGroup({
512
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
513
+ entries: [
514
+ { binding: 0, resource: gpuSampler },
515
+ { binding: 1, resource: intermediateView1 },
516
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
517
+ ],
518
+ });
519
+ const renderPassH = commandEncoder.beginRenderPass({
520
+ colorAttachments: [{
521
+ view: intermediateView2,
522
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
523
+ loadOp: 'clear',
524
+ storeOp: 'store',
525
+ }],
526
+ });
527
+ renderPassH.setPipeline(gpuBlurPipeline);
528
+ renderPassH.setBindGroup(0, blurBindGroupH);
529
+ renderPassH.draw(3, 1, 0, 0);
530
+ renderPassH.end();
531
+ gpuDevice.queue.submit([commandEncoder.finish()]);
532
+ // Pass 3b: Vertical blur (intermediate2 -> intermediate3)
533
+ commandEncoder = gpuDevice.createCommandEncoder();
534
+ const blurUniformsV = new Float32Array([0.0, 1.0, blurRadius, 0.0]);
535
+ gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsV);
536
+ const blurBindGroupV = gpuDevice.createBindGroup({
537
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
538
+ entries: [
539
+ { binding: 0, resource: gpuSampler },
540
+ { binding: 1, resource: intermediateView2 },
541
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
542
+ ],
543
+ });
544
+ const renderPassV = commandEncoder.beginRenderPass({
545
+ colorAttachments: [{
546
+ view: intermediateView3,
547
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
548
+ loadOp: 'clear',
549
+ storeOp: 'store',
550
+ }],
551
+ });
552
+ renderPassV.setPipeline(gpuBlurPipeline);
553
+ renderPassV.setBindGroup(0, blurBindGroupV);
554
+ renderPassV.draw(3, 1, 0, 0);
555
+ renderPassV.end();
556
+ gpuDevice.queue.submit([commandEncoder.finish()]);
557
+ // Pass 3c: Composite blurred region with accumulator (intermediate3 blurred + intermediate4 accumulator -> intermediate2)
558
+ commandEncoder = gpuDevice.createCommandEncoder();
559
+ const compositeUniforms = new Float32Array([minX, minY, maxX, maxY]);
560
+ gpuDevice.queue.writeBuffer(gpuCompositeUniformBuffer, 0, compositeUniforms);
561
+ const compositeBindGroup = gpuDevice.createBindGroup({
562
+ layout: gpuCompositePipeline.getBindGroupLayout(0),
563
+ entries: [
564
+ { binding: 0, resource: gpuSampler },
565
+ { binding: 1, resource: intermediateView3 }, // blurred
566
+ { binding: 2, resource: intermediateView4 }, // accumulator
567
+ { binding: 3, resource: { buffer: gpuCompositeUniformBuffer } },
568
+ ],
569
+ });
570
+ const renderPassComp = commandEncoder.beginRenderPass({
571
+ colorAttachments: [{
572
+ view: intermediateView2,
573
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
574
+ loadOp: 'clear',
575
+ storeOp: 'store',
576
+ }],
577
+ });
578
+ renderPassComp.setPipeline(gpuCompositePipeline);
579
+ renderPassComp.setBindGroup(0, compositeBindGroup);
580
+ renderPassComp.draw(3, 1, 0, 0);
581
+ renderPassComp.end();
582
+ gpuDevice.queue.submit([commandEncoder.finish()]);
583
+ // Pass 3d: Copy result back to accumulator (intermediate2 -> intermediate4)
584
+ commandEncoder = gpuDevice.createCommandEncoder();
585
+ gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
586
+ const copyBackBindGroup = gpuDevice.createBindGroup({
587
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
588
+ entries: [
589
+ { binding: 0, resource: gpuSampler },
590
+ { binding: 1, resource: intermediateView2 },
591
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
592
+ ],
593
+ });
594
+ const copyBackPass = commandEncoder.beginRenderPass({
595
+ colorAttachments: [{
596
+ view: intermediateView4,
597
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
598
+ loadOp: 'clear',
599
+ storeOp: 'store',
600
+ }],
601
+ });
602
+ copyBackPass.setPipeline(gpuBlurPipeline);
603
+ copyBackPass.setBindGroup(0, copyBackBindGroup);
604
+ copyBackPass.draw(3, 1, 0, 0);
605
+ copyBackPass.end();
606
+ gpuDevice.queue.submit([commandEncoder.finish()]);
607
+ }
608
+ // === Pass 4: Apply grain (if enabled) or copy final result to canvas ===
609
+ commandEncoder = gpuDevice.createCommandEncoder();
610
+ const canvasView = gpuContext.getCurrentTexture().createView();
611
+ if (grainAmount > 0) {
612
+ // Apply grain using grain pipeline
613
+ const rotationRad = (transform.rotation * Math.PI) / 180;
614
+ const grainUniforms = new Float32Array([
615
+ // Grain parameter (1 float)
616
+ grainAmount,
617
+ // Viewport (4 floats)
618
+ viewport.zoom,
619
+ viewport.offsetX,
620
+ viewport.offsetY,
621
+ viewport.scale,
622
+ // Transform (4 floats)
623
+ rotationRad,
624
+ transform.flipHorizontal ? -1.0 : 1.0,
625
+ transform.flipVertical ? -1.0 : 1.0,
626
+ transform.scale,
627
+ // Canvas dimensions (2 floats)
628
+ canvasWidth,
629
+ canvasHeight,
630
+ // Image dimensions (2 floats)
631
+ imageWidth,
632
+ imageHeight,
633
+ // Crop area (4 floats)
634
+ cropArea?.x ?? 0,
635
+ cropArea?.y ?? 0,
636
+ cropArea?.width ?? 0,
637
+ cropArea?.height ?? 0,
638
+ // Padding to 20 floats (17 used, 3 padding)
639
+ 0, 0, 0
640
+ ]);
641
+ gpuDevice.queue.writeBuffer(gpuGrainUniformBuffer, 0, grainUniforms);
642
+ const grainBindGroup = gpuDevice.createBindGroup({
643
+ layout: gpuGrainPipeline.getBindGroupLayout(0),
644
+ entries: [
645
+ { binding: 0, resource: gpuSampler },
646
+ { binding: 1, resource: intermediateView4 },
647
+ { binding: 2, resource: { buffer: gpuGrainUniformBuffer } },
648
+ ],
649
+ });
650
+ const renderPassGrain = commandEncoder.beginRenderPass({
651
+ colorAttachments: [{
652
+ view: canvasView,
653
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
654
+ loadOp: 'clear',
655
+ storeOp: 'store',
656
+ }],
657
+ });
658
+ renderPassGrain.setPipeline(gpuGrainPipeline);
659
+ renderPassGrain.setBindGroup(0, grainBindGroup);
660
+ renderPassGrain.draw(3, 1, 0, 0);
661
+ renderPassGrain.end();
662
+ }
663
+ else {
664
+ // No grain - simple copy to canvas
665
+ gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
666
+ const finalBindGroup = gpuDevice.createBindGroup({
667
+ layout: gpuBlurPipeline.getBindGroupLayout(0),
668
+ entries: [
669
+ { binding: 0, resource: gpuSampler },
670
+ { binding: 1, resource: intermediateView4 },
671
+ { binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
672
+ ],
673
+ });
674
+ const renderPassFinal = commandEncoder.beginRenderPass({
675
+ colorAttachments: [{
676
+ view: canvasView,
677
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
678
+ loadOp: 'clear',
679
+ storeOp: 'store',
680
+ }],
681
+ });
682
+ renderPassFinal.setPipeline(gpuBlurPipeline);
683
+ renderPassFinal.setBindGroup(0, finalBindGroup);
684
+ renderPassFinal.draw(3, 1, 0, 0);
685
+ renderPassFinal.end();
686
+ }
687
+ gpuDevice.queue.submit([commandEncoder.finish()]);
688
+ return true;
689
+ }
690
+ /**
691
+ * Check if WebGPU is initialized for canvas
692
+ */
693
+ export function isWebGPUInitialized() {
694
+ return gpuDevice !== null && gpuContext !== null && gpuPipeline !== null;
695
+ }
696
+ /**
697
+ * Export image using WebGPU rendering at full resolution
698
+ * Creates an offscreen canvas and renders the final image with all adjustments
699
+ */
700
+ export async function exportWithWebGPU(imageSource, adjustments, transform, cropArea = null, blurAreas = []) {
701
+ try {
702
+ // Get adapter and device
703
+ if (!navigator.gpu) {
704
+ console.warn('WebGPU not supported for export');
705
+ return null;
706
+ }
707
+ const adapter = await navigator.gpu.requestAdapter();
708
+ if (!adapter) {
709
+ console.warn('No WebGPU adapter for export');
710
+ return null;
711
+ }
712
+ const device = await adapter.requestDevice();
713
+ const format = navigator.gpu.getPreferredCanvasFormat();
714
+ // Calculate output dimensions based on crop and rotation
715
+ const sourceWidth = cropArea ? cropArea.width : imageSource.width;
716
+ const sourceHeight = cropArea ? cropArea.height : imageSource.height;
717
+ const needsSwap = transform.rotation === 90 || transform.rotation === 270;
718
+ const outputWidth = needsSwap ? sourceHeight : sourceWidth;
719
+ const outputHeight = needsSwap ? sourceWidth : sourceHeight;
720
+ // Create offscreen canvas at full resolution
721
+ const canvas = document.createElement('canvas');
722
+ canvas.width = outputWidth;
723
+ canvas.height = outputHeight;
724
+ const context = canvas.getContext('webgpu');
725
+ if (!context) {
726
+ console.warn('Failed to get WebGPU context for export');
727
+ return null;
728
+ }
729
+ context.configure({ device, format, alphaMode: 'premultiplied' });
730
+ // Upload image as texture
731
+ const bitmap = imageSource instanceof ImageBitmap
732
+ ? imageSource
733
+ : await createImageBitmap(imageSource);
734
+ const texture = device.createTexture({
735
+ size: [bitmap.width, bitmap.height, 1],
736
+ format: 'rgba8unorm',
737
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
738
+ });
739
+ device.queue.copyExternalImageToTexture({ source: bitmap }, { texture }, [bitmap.width, bitmap.height]);
740
+ // Create sampler
741
+ const sampler = device.createSampler({
742
+ magFilter: 'linear',
743
+ minFilter: 'linear',
744
+ addressModeU: 'clamp-to-edge',
745
+ addressModeV: 'clamp-to-edge',
746
+ });
747
+ // Create pipelines
748
+ const mainShaderModule = device.createShaderModule({ code: SHADER_CODE });
749
+ const mainPipeline = device.createRenderPipeline({
750
+ layout: 'auto',
751
+ vertex: { module: mainShaderModule, entryPoint: 'vs_main' },
752
+ fragment: {
753
+ module: mainShaderModule,
754
+ entryPoint: 'fs_main',
755
+ targets: [{ format: 'rgba8unorm' }],
756
+ },
757
+ primitive: { topology: 'triangle-list' },
758
+ });
759
+ const blurShaderModule = device.createShaderModule({ code: BLUR_SHADER_CODE });
760
+ const blurPipeline = device.createRenderPipeline({
761
+ layout: 'auto',
762
+ vertex: { module: blurShaderModule, entryPoint: 'vs_main' },
763
+ fragment: {
764
+ module: blurShaderModule,
765
+ entryPoint: 'fs_main',
766
+ targets: [{ format: 'rgba8unorm' }],
767
+ },
768
+ primitive: { topology: 'triangle-list' },
769
+ });
770
+ const grainShaderModule = device.createShaderModule({ code: GRAIN_SHADER_CODE });
771
+ const grainPipeline = device.createRenderPipeline({
772
+ layout: 'auto',
773
+ vertex: { module: grainShaderModule, entryPoint: 'vs_main' },
774
+ fragment: {
775
+ module: grainShaderModule,
776
+ entryPoint: 'fs_main',
777
+ targets: [{ format }],
778
+ },
779
+ primitive: { topology: 'triangle-list' },
780
+ });
781
+ const compositeShaderModule = device.createShaderModule({ code: COMPOSITE_SHADER_CODE });
782
+ const compositePipeline = device.createRenderPipeline({
783
+ layout: 'auto',
784
+ vertex: { module: compositeShaderModule, entryPoint: 'vs_main' },
785
+ fragment: {
786
+ module: compositeShaderModule,
787
+ entryPoint: 'fs_main',
788
+ targets: [{ format: 'rgba8unorm' }],
789
+ },
790
+ primitive: { topology: 'triangle-list' },
791
+ });
792
+ // Create uniform buffers
793
+ const mainUniformBuffer = device.createBuffer({
794
+ size: 128,
795
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
796
+ });
797
+ const blurUniformBuffer = device.createBuffer({
798
+ size: 16,
799
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
800
+ });
801
+ const grainUniformBuffer = device.createBuffer({
802
+ size: 80,
803
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
804
+ });
805
+ // Create intermediate textures
806
+ const intermediate1 = device.createTexture({
807
+ size: [outputWidth, outputHeight, 1],
808
+ format: 'rgba8unorm',
809
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
810
+ });
811
+ const intermediate2 = device.createTexture({
812
+ size: [outputWidth, outputHeight, 1],
813
+ format: 'rgba8unorm',
814
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
815
+ });
816
+ const intermediate3 = device.createTexture({
817
+ size: [outputWidth, outputHeight, 1],
818
+ format: 'rgba8unorm',
819
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
820
+ });
821
+ const intermediate4 = device.createTexture({
822
+ size: [outputWidth, outputHeight, 1],
823
+ format: 'rgba8unorm',
824
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
825
+ });
826
+ // Setup viewport and transform for export (no zoom/pan, centered)
827
+ const viewport = {
828
+ zoom: 1.0,
829
+ offsetX: 0,
830
+ offsetY: 0,
831
+ scale: 1.0,
832
+ };
833
+ // Prepare uniforms (grain=0 for main pass)
834
+ const rotationRad = (transform.rotation * Math.PI) / 180;
835
+ const uniformData = new Float32Array([
836
+ // Adjustments (11 floats) - grain set to 0
837
+ adjustments.brightness, adjustments.contrast, adjustments.exposure,
838
+ adjustments.highlights, adjustments.shadows, adjustments.saturation,
839
+ adjustments.temperature, adjustments.sepia, adjustments.grayscale,
840
+ adjustments.vignette, 0, // grain = 0
841
+ // Viewport (4 floats)
842
+ viewport.zoom, viewport.offsetX, viewport.offsetY, viewport.scale,
843
+ // Transform (4 floats)
844
+ rotationRad,
845
+ transform.flipHorizontal ? -1.0 : 1.0,
846
+ transform.flipVertical ? -1.0 : 1.0,
847
+ transform.scale,
848
+ // Canvas dimensions (2 floats)
849
+ outputWidth, outputHeight,
850
+ // Image dimensions (2 floats)
851
+ bitmap.width, bitmap.height,
852
+ // Crop area (4 floats)
853
+ cropArea?.x ?? 0, cropArea?.y ?? 0,
854
+ cropArea?.width ?? 0, cropArea?.height ?? 0,
855
+ // Padding
856
+ 0, 0, 0, 0, 0
857
+ ]);
858
+ device.queue.writeBuffer(mainUniformBuffer, 0, uniformData);
859
+ // Create bind group for main pass
860
+ const mainBindGroup = device.createBindGroup({
861
+ layout: mainPipeline.getBindGroupLayout(0),
862
+ entries: [
863
+ { binding: 0, resource: sampler },
864
+ { binding: 1, resource: texture.createView() },
865
+ { binding: 2, resource: { buffer: mainUniformBuffer } },
866
+ ],
867
+ });
868
+ // Pass 1: Render with adjustments
869
+ let commandEncoder = device.createCommandEncoder();
870
+ const renderPass1 = commandEncoder.beginRenderPass({
871
+ colorAttachments: [{
872
+ view: intermediate1.createView(),
873
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
874
+ loadOp: 'clear',
875
+ storeOp: 'store',
876
+ }],
877
+ });
878
+ renderPass1.setPipeline(mainPipeline);
879
+ renderPass1.setBindGroup(0, mainBindGroup);
880
+ renderPass1.draw(3, 1, 0, 0);
881
+ renderPass1.end();
882
+ device.queue.submit([commandEncoder.finish()]);
883
+ // Pass 2: Copy to accumulator
884
+ commandEncoder = device.createCommandEncoder();
885
+ device.queue.writeBuffer(blurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
886
+ const copyBindGroup = device.createBindGroup({
887
+ layout: blurPipeline.getBindGroupLayout(0),
888
+ entries: [
889
+ { binding: 0, resource: sampler },
890
+ { binding: 1, resource: intermediate1.createView() },
891
+ { binding: 2, resource: { buffer: blurUniformBuffer } },
892
+ ],
893
+ });
894
+ const copyPass = commandEncoder.beginRenderPass({
895
+ colorAttachments: [{
896
+ view: intermediate4.createView(),
897
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
898
+ loadOp: 'clear',
899
+ storeOp: 'store',
900
+ }],
901
+ });
902
+ copyPass.setPipeline(blurPipeline);
903
+ copyPass.setBindGroup(0, copyBindGroup);
904
+ copyPass.draw(3, 1, 0, 0);
905
+ copyPass.end();
906
+ device.queue.submit([commandEncoder.finish()]);
907
+ // Pass 3a: Apply global blur if needed
908
+ if (adjustments.blur > 0) {
909
+ const globalBlurRadius = Math.ceil((adjustments.blur / 100) * 10);
910
+ // Horizontal pass
911
+ commandEncoder = device.createCommandEncoder();
912
+ const blurUniformsH = new Float32Array([1.0, 0.0, globalBlurRadius, 0.0]);
913
+ device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsH);
914
+ const blurBindGroupH = device.createBindGroup({
915
+ layout: blurPipeline.getBindGroupLayout(0),
916
+ entries: [
917
+ { binding: 0, resource: sampler },
918
+ { binding: 1, resource: intermediate4.createView() },
919
+ { binding: 2, resource: { buffer: blurUniformBuffer } },
920
+ ],
921
+ });
922
+ const blurPassH = commandEncoder.beginRenderPass({
923
+ colorAttachments: [{
924
+ view: intermediate2.createView(),
925
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
926
+ loadOp: 'clear',
927
+ storeOp: 'store',
928
+ }],
929
+ });
930
+ blurPassH.setPipeline(blurPipeline);
931
+ blurPassH.setBindGroup(0, blurBindGroupH);
932
+ blurPassH.draw(3, 1, 0, 0);
933
+ blurPassH.end();
934
+ device.queue.submit([commandEncoder.finish()]);
935
+ // Vertical pass
936
+ commandEncoder = device.createCommandEncoder();
937
+ const blurUniformsV = new Float32Array([0.0, 1.0, globalBlurRadius, 0.0]);
938
+ device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsV);
939
+ const blurBindGroupV = device.createBindGroup({
940
+ layout: blurPipeline.getBindGroupLayout(0),
941
+ entries: [
942
+ { binding: 0, resource: sampler },
943
+ { binding: 1, resource: intermediate2.createView() },
944
+ { binding: 2, resource: { buffer: blurUniformBuffer } },
945
+ ],
946
+ });
947
+ const blurPassV = commandEncoder.beginRenderPass({
948
+ colorAttachments: [{
949
+ view: intermediate4.createView(),
950
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
951
+ loadOp: 'clear',
952
+ storeOp: 'store',
953
+ }],
954
+ });
955
+ blurPassV.setPipeline(blurPipeline);
956
+ blurPassV.setBindGroup(0, blurBindGroupV);
957
+ blurPassV.draw(3, 1, 0, 0);
958
+ blurPassV.end();
959
+ device.queue.submit([commandEncoder.finish()]);
960
+ }
961
+ // Pass 3b: Apply regional blur areas
962
+ if (blurAreas.length > 0) {
963
+ // Create composite uniform buffer for blur area compositing
964
+ const compositeUniformBuffer = device.createBuffer({
965
+ size: 16,
966
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
967
+ });
968
+ // For export, we use a centered viewport with no zoom/pan
969
+ // Image is rendered at full resolution, centered
970
+ const totalScale = viewport.scale * viewport.zoom * transform.scale; // All 1.0 for export
971
+ const centerX = outputWidth / 2;
972
+ const centerY = outputHeight / 2;
973
+ // Determine source dimensions based on crop
974
+ const sourceWidth = cropArea ? cropArea.width : bitmap.width;
975
+ const sourceHeight = cropArea ? cropArea.height : bitmap.height;
976
+ const cropOffsetX = cropArea ? cropArea.x : 0;
977
+ const cropOffsetY = cropArea ? cropArea.y : 0;
978
+ for (const blurArea of blurAreas) {
979
+ if (blurArea.blurStrength <= 0)
980
+ continue;
981
+ // Calculate blur radius to match Canvas2D behavior
982
+ // For export, totalScale = 1.0, so blur is in image pixels
983
+ const imageBlurPx = (blurArea.blurStrength / 100) * 100;
984
+ const blurRadius = Math.ceil(imageBlurPx * totalScale);
985
+ // Convert blur area from image space to canvas space, then to UV coordinates
986
+ // 1. Convert to crop-relative coordinates
987
+ const relativeX = blurArea.x - cropOffsetX;
988
+ const relativeY = blurArea.y - cropOffsetY;
989
+ // 2. Transform from image space to canvas space
990
+ // For export: viewport offset = 0, zoom = 1, scale = 1
991
+ const canvasBlurX = (relativeX - sourceWidth / 2) * totalScale + centerX + viewport.offsetX;
992
+ const canvasBlurY = (relativeY - sourceHeight / 2) * totalScale + centerY + viewport.offsetY;
993
+ const canvasBlurWidth = blurArea.width * totalScale;
994
+ const canvasBlurHeight = blurArea.height * totalScale;
995
+ // 3. Convert to normalized UV coordinates (0-1)
996
+ const minX = clamp(canvasBlurX / outputWidth, 0, 1);
997
+ const minY = clamp(canvasBlurY / outputHeight, 0, 1);
998
+ const maxX = clamp((canvasBlurX + canvasBlurWidth) / outputWidth, 0, 1);
999
+ const maxY = clamp((canvasBlurY + canvasBlurHeight) / outputHeight, 0, 1);
1000
+ // Horizontal blur: intermediate1 (base) -> intermediate2
1001
+ commandEncoder = device.createCommandEncoder();
1002
+ const blurUniformsH = new Float32Array([1.0, 0.0, blurRadius, 0.0]);
1003
+ device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsH);
1004
+ const blurBindGroupH = device.createBindGroup({
1005
+ layout: blurPipeline.getBindGroupLayout(0),
1006
+ entries: [
1007
+ { binding: 0, resource: sampler },
1008
+ { binding: 1, resource: intermediate1.createView() },
1009
+ { binding: 2, resource: { buffer: blurUniformBuffer } },
1010
+ ],
1011
+ });
1012
+ const blurPassH = commandEncoder.beginRenderPass({
1013
+ colorAttachments: [{
1014
+ view: intermediate2.createView(),
1015
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
1016
+ loadOp: 'clear',
1017
+ storeOp: 'store',
1018
+ }],
1019
+ });
1020
+ blurPassH.setPipeline(blurPipeline);
1021
+ blurPassH.setBindGroup(0, blurBindGroupH);
1022
+ blurPassH.draw(3, 1, 0, 0);
1023
+ blurPassH.end();
1024
+ device.queue.submit([commandEncoder.finish()]);
1025
+ // Vertical blur: intermediate2 -> intermediate3
1026
+ commandEncoder = device.createCommandEncoder();
1027
+ const blurUniformsV = new Float32Array([0.0, 1.0, blurRadius, 0.0]);
1028
+ device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsV);
1029
+ const blurBindGroupV = device.createBindGroup({
1030
+ layout: blurPipeline.getBindGroupLayout(0),
1031
+ entries: [
1032
+ { binding: 0, resource: sampler },
1033
+ { binding: 1, resource: intermediate2.createView() },
1034
+ { binding: 2, resource: { buffer: blurUniformBuffer } },
1035
+ ],
1036
+ });
1037
+ const blurPassV = commandEncoder.beginRenderPass({
1038
+ colorAttachments: [{
1039
+ view: intermediate3.createView(),
1040
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
1041
+ loadOp: 'clear',
1042
+ storeOp: 'store',
1043
+ }],
1044
+ });
1045
+ blurPassV.setPipeline(blurPipeline);
1046
+ blurPassV.setBindGroup(0, blurBindGroupV);
1047
+ blurPassV.draw(3, 1, 0, 0);
1048
+ blurPassV.end();
1049
+ device.queue.submit([commandEncoder.finish()]);
1050
+ // Composite: blend intermediate3 (blurred region) with intermediate4 (accumulator)
1051
+ commandEncoder = device.createCommandEncoder();
1052
+ const compositeUniforms = new Float32Array([minX, minY, maxX, maxY]);
1053
+ device.queue.writeBuffer(compositeUniformBuffer, 0, compositeUniforms);
1054
+ const compositeBindGroup = device.createBindGroup({
1055
+ layout: compositePipeline.getBindGroupLayout(0),
1056
+ entries: [
1057
+ { binding: 0, resource: sampler },
1058
+ { binding: 1, resource: intermediate3.createView() }, // blurred
1059
+ { binding: 2, resource: intermediate4.createView() }, // accumulator
1060
+ { binding: 3, resource: { buffer: compositeUniformBuffer } },
1061
+ ],
1062
+ });
1063
+ const compositePass = commandEncoder.beginRenderPass({
1064
+ colorAttachments: [{
1065
+ view: intermediate2.createView(),
1066
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
1067
+ loadOp: 'clear',
1068
+ storeOp: 'store',
1069
+ }],
1070
+ });
1071
+ compositePass.setPipeline(compositePipeline);
1072
+ compositePass.setBindGroup(0, compositeBindGroup);
1073
+ compositePass.draw(3, 1, 0, 0);
1074
+ compositePass.end();
1075
+ device.queue.submit([commandEncoder.finish()]);
1076
+ // Copy back to accumulator: intermediate2 -> intermediate4
1077
+ commandEncoder = device.createCommandEncoder();
1078
+ device.queue.writeBuffer(blurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
1079
+ const copyBackBindGroup = device.createBindGroup({
1080
+ layout: blurPipeline.getBindGroupLayout(0),
1081
+ entries: [
1082
+ { binding: 0, resource: sampler },
1083
+ { binding: 1, resource: intermediate2.createView() },
1084
+ { binding: 2, resource: { buffer: blurUniformBuffer } },
1085
+ ],
1086
+ });
1087
+ const copyBackPass = commandEncoder.beginRenderPass({
1088
+ colorAttachments: [{
1089
+ view: intermediate4.createView(),
1090
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
1091
+ loadOp: 'clear',
1092
+ storeOp: 'store',
1093
+ }],
1094
+ });
1095
+ copyBackPass.setPipeline(blurPipeline);
1096
+ copyBackPass.setBindGroup(0, copyBackBindGroup);
1097
+ copyBackPass.draw(3, 1, 0, 0);
1098
+ copyBackPass.end();
1099
+ device.queue.submit([commandEncoder.finish()]);
1100
+ }
1101
+ // Cleanup composite buffer
1102
+ compositeUniformBuffer.destroy();
1103
+ }
1104
+ // Pass 4: Apply grain or copy to canvas
1105
+ // Note: Always use grain pipeline to render to canvas because it has the correct format
1106
+ // When grain is 0, the grain shader will just pass through the image
1107
+ commandEncoder = device.createCommandEncoder();
1108
+ const canvasView = context.getCurrentTexture().createView();
1109
+ const grainUniforms = new Float32Array([
1110
+ adjustments.grain, // Use actual grain value (0 if no grain)
1111
+ viewport.zoom, viewport.offsetX, viewport.offsetY, viewport.scale,
1112
+ rotationRad,
1113
+ transform.flipHorizontal ? -1.0 : 1.0,
1114
+ transform.flipVertical ? -1.0 : 1.0,
1115
+ transform.scale,
1116
+ outputWidth, outputHeight,
1117
+ bitmap.width, bitmap.height,
1118
+ cropArea?.x ?? 0, cropArea?.y ?? 0,
1119
+ cropArea?.width ?? 0, cropArea?.height ?? 0,
1120
+ 0, 0, 0
1121
+ ]);
1122
+ device.queue.writeBuffer(grainUniformBuffer, 0, grainUniforms);
1123
+ const grainBindGroup = device.createBindGroup({
1124
+ layout: grainPipeline.getBindGroupLayout(0),
1125
+ entries: [
1126
+ { binding: 0, resource: sampler },
1127
+ { binding: 1, resource: intermediate4.createView() },
1128
+ { binding: 2, resource: { buffer: grainUniformBuffer } },
1129
+ ],
1130
+ });
1131
+ const grainPass = commandEncoder.beginRenderPass({
1132
+ colorAttachments: [{
1133
+ view: canvasView,
1134
+ clearValue: { r: 0, g: 0, b: 0, a: 1 },
1135
+ loadOp: 'clear',
1136
+ storeOp: 'store',
1137
+ }],
1138
+ });
1139
+ grainPass.setPipeline(grainPipeline);
1140
+ grainPass.setBindGroup(0, grainBindGroup);
1141
+ grainPass.draw(3, 1, 0, 0);
1142
+ grainPass.end();
1143
+ device.queue.submit([commandEncoder.finish()]);
1144
+ // Wait for rendering to complete
1145
+ await device.queue.onSubmittedWorkDone();
1146
+ // Cleanup
1147
+ texture.destroy();
1148
+ intermediate1.destroy();
1149
+ intermediate2.destroy();
1150
+ intermediate3.destroy();
1151
+ intermediate4.destroy();
1152
+ mainUniformBuffer.destroy();
1153
+ blurUniformBuffer.destroy();
1154
+ grainUniformBuffer.destroy();
1155
+ return canvas;
1156
+ }
1157
+ catch (error) {
1158
+ console.error('Failed to export with WebGPU:', error);
1159
+ return null;
1160
+ }
1161
+ }
1162
+ /**
1163
+ * Cleanup WebGPU resources
1164
+ */
1165
+ export function cleanupWebGPU() {
1166
+ gpuTexture?.destroy();
1167
+ gpuUniformBuffer?.destroy();
1168
+ gpuBlurUniformBuffer?.destroy();
1169
+ gpuCompositeUniformBuffer?.destroy();
1170
+ gpuGrainUniformBuffer?.destroy();
1171
+ gpuIntermediateTexture?.destroy();
1172
+ gpuIntermediateTexture2?.destroy();
1173
+ gpuIntermediateTexture3?.destroy();
1174
+ gpuIntermediateTexture4?.destroy();
1175
+ gpuDevice = null;
1176
+ gpuContext = null;
1177
+ gpuPipeline = null;
1178
+ gpuUniformBuffer = null;
1179
+ gpuSampler = null;
1180
+ gpuTexture = null;
1181
+ gpuBindGroup = null;
1182
+ gpuBlurPipeline = null;
1183
+ gpuBlurUniformBuffer = null;
1184
+ gpuCompositePipeline = null;
1185
+ gpuCompositeUniformBuffer = null;
1186
+ gpuGrainPipeline = null;
1187
+ gpuGrainUniformBuffer = null;
1188
+ gpuIntermediateTexture = null;
1189
+ gpuIntermediateTexture2 = null;
1190
+ gpuIntermediateTexture3 = null;
1191
+ gpuIntermediateTexture4 = null;
1192
+ }