raw-webgpu 0.1.0-alpha.2 → 0.1.0

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/README.md CHANGED
@@ -10,15 +10,15 @@ LibRaw, Adobe DNG SDK and libjxl decode files in a WASM worker. Simple TIFF stri
10
10
  - GPU Bayer and X-Trans demosaic, with library CPU preparation for special layouts and mandatory DNG corrections.
11
11
  - Absolute temperature/tint and initial white-balance restoration without decoding again.
12
12
  - HDR output, camera calibration, orientation and explicit resource disposal.
13
- - Experimental TIFF support with ICC matrix/TRC profiles and alpha.
13
+ - TIFF support with ICC matrix/TRC profiles and alpha.
14
14
 
15
15
  ## Installation
16
16
 
17
17
  ```sh
18
- npm install --save-exact raw-webgpu@0.1.0-alpha.2
18
+ npm install --save-exact raw-webgpu@0.1.0
19
19
  ```
20
20
 
21
- WASM, workers and types are included; consumers do not compile C++. Use a WebGPU browser with WASM SIMD/exception support and a bundler that handles worker/WASM asset URLs, such as Vite. Older TypeScript versions may need `@webgpu/types` in `compilerOptions.types`.
21
+ WASM, workers and types are included; consumers do not compile C++. Use a WebGPU browser with WASM SIMD/exception support and a bundler that handles worker/WASM asset URLs, such as Vite. TypeScript 5.9 and newer are tested; with TypeScript 5.9, install `@webgpu/types` and include it in `compilerOptions.types`.
22
22
 
23
23
  ## Usage
24
24
 
@@ -61,15 +61,28 @@ Use separate passes for independent previews or exports. If passing an external
61
61
 
62
62
  For file export, see the [Bun PNG/JPEG/BMP conversion example](docs/conversion.md).
63
63
 
64
+
65
+ ## API contract and support
66
+
67
+ The supported browser baseline is Chromium with WebGPU. Verified environments are Chromium 151 on macOS with Apple M4 Pro and Linux CI with software rendering, plus Chrome 153 on macOS. Safari, Firefox, mobile browsers and other physical GPUs have not been validated. Tests simulate lower texture/buffer limits; they do not establish a device memory budget. Bun is an additional tested runtime, not a CPU fallback.
68
+
69
+ - Developed RAW and decoded TIFF output is linear Rec.2020/D65 `rgba16float`, with no display tone curve. HDR values can exceed 1. TIFF alpha is straight. Camera JPEG previews apply their own rendering and are not pixel references for this output.
70
+ - `source.texture` contains sensor or camera-RGB samples, not developed output. Read its format and metadata; do not assume every RAW is a one-channel mosaic. `source.size` is oriented output size; `metadata.size` is the unrotated sample size.
71
+ - Calibration has three RGB gains and a nine-value column-major matrix. Development applies gains before the camera-to-working-space matrix; exposure is in stops. Independent passes can use different calibration without changing the source.
72
+ - A decoder owns its sources, each source owns its passes and worker, and the caller owns destination textures. Disposal is idempotent. Device loss closes the decoder; create another decoder with a new device. Pending loads and calibration requests reject when their owner closes.
73
+ - Abort signals cover loading only, including pending GPU setup. Cancellation rejects with the signal's reason. Invalid or unsupported files reject with an `Error`; message text is diagnostic, not a stable error code. Load completion queues GPU work; wait on the device queue when you need GPU completion.
74
+
75
+ The public API and output contract are the supported baseline for 0.1.x. Release checks compare synthetic pixels against known color math and demosaic filters, test real workers and resource cleanup, and install the actual npm tarball in a separate consumer using TypeScript 5.9. The retained 30-camera corpus is a manual compatibility/regression check; X-Trans detail and Sigma color remain explicitly experimental in 0.1.0.
76
+
64
77
  ## Limitations and planned work
65
78
 
66
79
  **X-Trans quality remains experimental.** Two GPU passes reconstruct camera RGB: nearby samples provide an initial estimate, then interpolated R−G and B−G differences recover detail. White balance is applied after this fixed reconstruction; it is not equivalent to running a WB-dependent demosaic again. Further work should improve directional edges and aliasing against the retained LibRaw Markesteijn references.
67
80
 
68
81
  The camera-RGB cache keeps WB edits fast. A 16 MP image retains about 128 MB of RGB plus the 32 MB mosaic; reconstruction temporarily needs another 128 MB texture. That temporary texture is released after submission.
69
82
 
70
- RAW coverage depends on camera calibration and sensor layout. Missing as-shot multipliers use a reported daylight fallback; Sigma color remains experimental. Floating-point RAW input, baked white balance and non-three-color sensors have limitations. CPU-prepared sources cannot expose pre-demosaic edits. There is no GPU denoising, highlight reconstruction or CPU rendering fallback.
83
+ RAW coverage depends on camera calibration and sensor layout. Missing as-shot multipliers use a reported daylight fallback; Sigma color remains experimental and can have visible color casts. Floating-point RAW input, baked white balance and non-three-color sensors have limitations. CPU-prepared sources cannot expose pre-demosaic edits. There is no GPU denoising, highlight reconstruction or CPU rendering fallback.
71
84
 
72
- TIFF passes 20 of 24 fixtures; bilevel, palette, float64 and YCbCr JPEG are rejected. Only the first IFD is read, and unsupported ICC profiles fall back to sRGB. Images must fit the GPU's texture limits.
85
+ TIFF passes 20 of 24 fixtures; bilevel, palette, float64 and YCbCr JPEG are rejected. Only the first IFD is read, and unsupported or malformed embedded ICC profiles are rejected. Untagged integer TIFF defaults to sRGB; untagged floating-point TIFF is treated as linear sRGB. Images must fit the GPU's texture limits.
73
86
 
74
87
  ## Benchmark
75
88
 
@@ -103,7 +116,7 @@ The native build downloads checksum-pinned dependencies and caches objects in `.
103
116
 
104
117
  `test:package` builds a tarball, installs it in a temporary project, checks types and runs RAW/TIFF tests in Chromium with WebGPU. `bun run test:gpu` checks shader pixels; `bun run check` formats and lints.
105
118
 
106
- Publishing runs in GitHub Actions. Merge the version change into `main`, then publish a GitHub prerelease with the matching tag, such as `v0.1.0-alpha.2`. The workflow verifies the tag and commit, installs Emscripten, and runs the package checks before publishing to npm's `alpha` channel. Local publishing is not required.
119
+ Publishing runs in GitHub Actions. Merge the version change into `main`, then publish a GitHub Release with the matching tag, such as `v0.1.0`. The workflow verifies the tag and commit, installs Emscripten, and runs the package checks before publishing to npm. Stable releases use `latest`; `-alpha.N` prereleases use `alpha`. Local publishing is not required.
107
120
 
108
121
  Configure [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) once for owner `roprgm`, repository `raw-webgpu`, workflow `publish.yml`, with publishing allowed and no environment name. No npm token secret is needed; npm attaches provenance automatically.
109
122
 
@@ -0,0 +1,2 @@
1
+ /** One device-lost subscription; individual loads remove their abort listeners. */
2
+ export declare function deviceSignal(device: GPUDevice): AbortSignal;
package/dist/index.js CHANGED
@@ -1,3 +1,16 @@
1
+ // src/decode/device.ts
2
+ var signals = new WeakMap;
3
+ function deviceSignal(device) {
4
+ let signal = signals.get(device);
5
+ if (!signal) {
6
+ const controller = new AbortController;
7
+ device.lost.then(() => controller.abort(Error("WebGPU device was lost.")));
8
+ signal = controller.signal;
9
+ signals.set(device, signal);
10
+ }
11
+ return signal;
12
+ }
13
+
1
14
  // src/decode/module.ts
2
15
  var compiled;
3
16
  async function compile() {
@@ -35,7 +48,11 @@ function createSession(compiled2) {
35
48
  }
36
49
  pending.clear();
37
50
  }
38
- compiled2.then((module) => worker.postMessage({ module }), (error) => dispose(error instanceof Error ? error : Error(String(error))));
51
+ compiled2.then((module) => {
52
+ if (!failure) {
53
+ worker.postMessage({ module });
54
+ }
55
+ }, (error) => dispose(error instanceof Error ? error : Error(String(error))));
39
56
  worker.onmessage = ({
40
57
  data
41
58
  }) => {
@@ -539,7 +556,9 @@ fn oriented(p: vec2u) -> vec2u {
539
556
  }
540
557
  }
541
558
 
542
- @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) id: vec3u) {
559
+ override workgroupRows: u32 = 16;
560
+
561
+ @compute @workgroup_size(16, workgroupRows) fn main(@builtin(global_invocation_id) id: vec3u) {
543
562
  if id.x >= params.width || id.y >= params.rows { return; }
544
563
  let offset = id.y * params.rowBytes + id.x * params.channels * params.bytes;
545
564
 
@@ -568,9 +587,14 @@ function createFilledBuffer(device, data, usage) {
568
587
  usage,
569
588
  mappedAtCreation: true
570
589
  });
571
- new Uint8Array(result.getMappedRange()).set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
572
- result.unmap();
573
- return result;
590
+ try {
591
+ new Uint8Array(result.getMappedRange()).set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
592
+ result.unmap();
593
+ return result;
594
+ } catch (error) {
595
+ result.destroy();
596
+ throw error;
597
+ }
574
598
  }
575
599
  function createParams(pixels, startRow, rows) {
576
600
  const params = new ArrayBuffer(96);
@@ -585,14 +609,16 @@ function createParams(pixels, startRow, rows) {
585
609
  }
586
610
  return new Uint8Array(params);
587
611
  }
588
- async function uploadTiff(device, pixels) {
612
+ async function uploadTiff(device, pixels, signal) {
613
+ const workgroupRows = Math.min(16, Math.floor(device.limits.maxComputeInvocationsPerWorkgroup / 16));
589
614
  let pending = pipelines.get(device);
590
615
  if (!pending) {
591
616
  pending = device.createComputePipelineAsync({
592
617
  layout: "auto",
593
618
  compute: {
594
619
  module: device.createShaderModule({ code: upload_default }),
595
- entryPoint: "main"
620
+ entryPoint: "main",
621
+ constants: { workgroupRows }
596
622
  }
597
623
  }).catch((error) => {
598
624
  pipelines.delete(device);
@@ -601,6 +627,7 @@ async function uploadTiff(device, pixels) {
601
627
  pipelines.set(device, pending);
602
628
  }
603
629
  const pipeline = await pending;
630
+ signal?.throwIfAborted();
604
631
  const { data, metadata, color } = pixels;
605
632
  const [width, height, , , , orientation, , , rowBytes] = metadata;
606
633
  const size = orientation >= 5 ? [height, width] : [width, height];
@@ -616,13 +643,15 @@ async function uploadTiff(device, pixels) {
616
643
  format: "rgba16float",
617
644
  usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
618
645
  });
619
- const curves = createFilledBuffer(device, color.table, GPUBufferUsage.STORAGE);
646
+ let curves;
620
647
  try {
648
+ curves = createFilledBuffer(device, color.table, GPUBufferUsage.STORAGE);
621
649
  for (let y = 0;y < height; y += rowsPerBand) {
622
650
  const rows = Math.min(rowsPerBand, height - y);
623
651
  const source = createFilledBuffer(device, data.subarray(y * rowBytes, (y + rows) * rowBytes), GPUBufferUsage.STORAGE);
624
- const params = createFilledBuffer(device, createParams(pixels, y, rows), GPUBufferUsage.UNIFORM);
652
+ let params;
625
653
  try {
654
+ params = createFilledBuffer(device, createParams(pixels, y, rows), GPUBufferUsage.UNIFORM);
626
655
  const bindings = device.createBindGroup({
627
656
  layout: pipeline.getBindGroupLayout(0),
628
657
  entries: [
@@ -636,12 +665,12 @@ async function uploadTiff(device, pixels) {
636
665
  const pass = encoder.beginComputePass();
637
666
  pass.setPipeline(pipeline);
638
667
  pass.setBindGroup(0, bindings);
639
- pass.dispatchWorkgroups(Math.ceil(width / 16), Math.ceil(rows / 16));
668
+ pass.dispatchWorkgroups(Math.ceil(width / 16), Math.ceil(rows / workgroupRows));
640
669
  pass.end();
641
670
  device.queue.submit([encoder.finish()]);
642
671
  } finally {
643
672
  source.destroy();
644
- params.destroy();
673
+ params?.destroy();
645
674
  }
646
675
  }
647
676
  return { texture, size, dispose: () => texture.destroy() };
@@ -649,21 +678,20 @@ async function uploadTiff(device, pixels) {
649
678
  texture.destroy();
650
679
  throw error;
651
680
  } finally {
652
- curves.destroy();
681
+ curves?.destroy();
653
682
  }
654
683
  }
655
684
 
656
685
  // src/tiff/index.ts
657
- async function decodeTiff(device, file, { signal } = {}) {
658
- if (signal?.aborted) {
659
- throw signal.reason;
660
- }
686
+ async function decodeTiff(device, file, { signal: requestedSignal } = {}) {
687
+ const signal = requestedSignal ? AbortSignal.any([requestedSignal, deviceSignal(device)]) : deviceSignal(device);
688
+ signal.throwIfAborted();
661
689
  const worker = new Worker(new URL("./tiff-worker.js", import.meta.url), {
662
690
  type: "module"
663
691
  });
664
692
  const aborted = Promise.withResolvers();
665
- const abort = () => aborted.reject(signal?.reason);
666
- signal?.addEventListener("abort", abort);
693
+ const abort = () => aborted.reject(signal.reason);
694
+ signal.addEventListener("abort", abort);
667
695
  try {
668
696
  const module = await Promise.race([compileDecoder(), aborted.promise]);
669
697
  const decoded = new Promise((resolve, reject) => {
@@ -682,9 +710,16 @@ async function decodeTiff(device, file, { signal } = {}) {
682
710
  worker.postMessage({ file, module });
683
711
  });
684
712
  const pixels = await Promise.race([decoded, aborted.promise]);
685
- return await uploadTiff(device, pixels);
713
+ const uploading = uploadTiff(device, pixels, signal).then((image) => {
714
+ if (signal.aborted) {
715
+ image.dispose();
716
+ throw signal.reason;
717
+ }
718
+ return image;
719
+ });
720
+ return await Promise.race([uploading, aborted.promise]);
686
721
  } finally {
687
- signal?.removeEventListener("abort", abort);
722
+ signal.removeEventListener("abort", abort);
688
723
  worker.terminate();
689
724
  }
690
725
  }
@@ -696,14 +731,16 @@ function createRawDecoder(device) {
696
731
  let closed = false;
697
732
  function dispose() {
698
733
  closed = true;
734
+ lost.removeEventListener("abort", dispose);
699
735
  for (const close of sources) {
700
736
  close();
701
737
  }
702
738
  }
703
- device.lost.then(dispose);
739
+ const lost = deviceSignal(device);
740
+ lost.addEventListener("abort", dispose, { once: true });
704
741
  return {
705
742
  async load(file, { signal } = {}) {
706
- if (closed) {
743
+ if (closed || lost.aborted) {
707
744
  throw Error("RAW decoder is closed.");
708
745
  }
709
746
  if (signal?.aborted) {
@@ -6,7 +6,7 @@ type Profile = {
6
6
  };
7
7
  /**
8
8
  * Reads a matrix/TRC profile: RGB colorants and a curve per channel, or a single curve for gray.
9
- * Unsupported lookup-table profiles and malformed profiles fall back to sRGB.
9
+ * Unsupported lookup-table profiles and malformed profiles are not accepted.
10
10
  */
11
11
  export declare function readProfile(bytes: Uint8Array): Profile | undefined;
12
12
  /** ICC curves and camera-independent conversion into linear Rec.2020. */
@@ -1,6 +1,6 @@
1
1
  import type { LoadOptions } from "../types";
2
2
  /** Decode TIFF into an owned linear Rec.2020 RGBA16F texture, with straight alpha. */
3
- export declare function decodeTiff(device: GPUDevice, file: Blob, { signal }?: LoadOptions): Promise<{
3
+ export declare function decodeTiff(device: GPUDevice, file: Blob, { signal: requestedSignal }?: LoadOptions): Promise<{
4
4
  texture: GPUTexture;
5
5
  size: [number, number];
6
6
  dispose: () => undefined;
@@ -14,7 +14,7 @@ export type TiffPixels = {
14
14
  matrix: number[];
15
15
  };
16
16
  };
17
- export declare function uploadTiff(device: GPUDevice, pixels: TiffPixels): Promise<{
17
+ export declare function uploadTiff(device: GPUDevice, pixels: TiffPixels, signal?: AbortSignal): Promise<{
18
18
  texture: GPUTexture;
19
19
  size: [number, number];
20
20
  dispose: () => undefined;
@@ -48,20 +48,38 @@ function readProfile(bytes) {
48
48
  return;
49
49
  }
50
50
  const tags = new Map;
51
+ if (view.getUint32(128) > (bytes.length - 132) / 12) {
52
+ return;
53
+ }
51
54
  for (let i = 0, count = view.getUint32(128);i < count; i++) {
52
- tags.set(text(132 + i * 12), view.getUint32(136 + i * 12));
55
+ const at = view.getUint32(136 + i * 12);
56
+ const size = view.getUint32(140 + i * 12);
57
+ if (at < 128 || size < 8 || at > bytes.length - size) {
58
+ return;
59
+ }
60
+ tags.set(text(132 + i * 12), { at, size });
61
+ }
62
+ if ([...tags.keys()].some((name) => /^(A2B|B2A|D2B|B2D)/.test(name))) {
63
+ return;
53
64
  }
54
65
  const xyz = (name) => {
55
- const at = tags.get(name);
56
- return at === undefined ? undefined : [fixed(at + 8), fixed(at + 12), fixed(at + 16)];
66
+ const tag = tags.get(name);
67
+ if (!tag || tag.size < 20 || text(tag.at) !== "XYZ ") {
68
+ return;
69
+ }
70
+ return [fixed(tag.at + 8), fixed(tag.at + 12), fixed(tag.at + 16)];
57
71
  };
58
72
  const curve = (name) => {
59
- const at = tags.get(name);
60
- if (at === undefined) {
73
+ const tag = tags.get(name);
74
+ if (!tag || tag.size < 12) {
61
75
  return;
62
76
  }
77
+ const { at, size } = tag;
63
78
  if (text(at) === "curv") {
64
79
  const count = view.getUint32(at + 8);
80
+ if (count > (size - 12) / 2) {
81
+ return;
82
+ }
65
83
  const entry = (i) => view.getUint16(at + 12 + 2 * Math.min(i, count - 1)) / 65535;
66
84
  if (count === 0) {
67
85
  return identity;
@@ -78,7 +96,11 @@ function readProfile(bytes) {
78
96
  }
79
97
  if (text(at) === "para") {
80
98
  const kind = view.getUint16(at + 8);
81
- const [g, a = 1, b = 0, c = 0, d = 0, e = 0, f = 0] = Array.from({ length: [1, 3, 4, 5, 7][kind] ?? 0 }, (_, i) => fixed(at + 12 + i * 4));
99
+ const count = [1, 3, 4, 5, 7][kind];
100
+ if (!count || count > (size - 12) / 4) {
101
+ return;
102
+ }
103
+ const [g, a = 1, b = 0, c = 0, d = 0, e = 0, f = 0] = Array.from({ length: count }, (_, i) => fixed(at + 12 + i * 4));
82
104
  if (kind === 0) {
83
105
  return (x) => x ** g;
84
106
  }
@@ -111,11 +133,13 @@ function readProfile(bytes) {
111
133
  }
112
134
  var sampleCurves = (curves) => Float32Array.from({ length: 3 * tableSize }, (_, i) => curves[Math.floor(i / tableSize)](i % tableSize / (tableSize - 1)));
113
135
  function tiffColor(icc, linear) {
114
- let profile = (icc && readProfile(icc)) ?? srgb;
115
- let table = sampleCurves(linear ? [identity, identity, identity] : profile.curves);
136
+ const profile = icc ? readProfile(icc) : srgb;
137
+ if (!profile) {
138
+ throw Error("Unsupported or malformed TIFF ICC profile.");
139
+ }
140
+ const table = sampleCurves(linear ? [identity, identity, identity] : profile.curves);
116
141
  if (!table.every(Number.isFinite)) {
117
- profile = srgb;
118
- table = sampleCurves(profile.curves);
142
+ throw Error("Malformed TIFF ICC transfer curve.");
119
143
  }
120
144
  return {
121
145
  table,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "raw-webgpu",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0",
4
4
  "description": "Camera RAW, DNG and TIFF decoding with WebGPU development and interactive white balance.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public",
48
- "tag": "alpha",
48
+ "tag": "latest",
49
49
  "registry": "https://registry.npmjs.org/"
50
50
  },
51
51
  "keywords": [