raw-webgpu 0.1.0-alpha.2 → 0.1.1
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 +28 -7
- package/dist/decode/device.d.ts +2 -0
- package/dist/develop/gpu.d.ts +3 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +118 -45
- package/dist/tiff/color.d.ts +1 -1
- package/dist/tiff/index.d.ts +1 -1
- package/dist/tiff/upload.d.ts +1 -1
- package/dist/tiff-worker.js +34 -10
- package/dist/types.d.ts +6 -0
- package/docs/conversion.md +4 -19
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# raw-webgpu
|
|
2
2
|
|
|
3
|
-
Load camera RAW, DNG and TIFF files into WebGPU textures. Develop into linear Rec.2020
|
|
3
|
+
Load camera RAW, DNG and TIFF files into WebGPU textures. Develop into linear Rec.2020 or display sRGB, with adjustable white balance and exposure.
|
|
4
4
|
|
|
5
5
|
LibRaw, Adobe DNG SDK and libjxl decode files in a WASM worker. Simple TIFF strips use original file bytes or browser Deflate; complex layouts retain the SDK fallback. WebGPU handles supported demosaic and color processing. Supply your own `GPUDevice`; no rendering framework or runtime dependencies are required.
|
|
6
6
|
|
|
@@ -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
|
-
-
|
|
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.
|
|
18
|
+
npm install --save-exact raw-webgpu@0.1.1
|
|
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.
|
|
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
|
|
|
@@ -45,6 +45,10 @@ destination.destroy();
|
|
|
45
45
|
decoder.dispose();
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
For direct display, create a pass with `{ outputColorSpace: "srgb", format }`, where `format` matches your sRGB canvas configuration, usually `navigator.gpu.getPreferredCanvasFormat()`. Render to `context.getCurrentTexture()` with the canvas dimensions set to `source.size`. No app shader is needed.
|
|
49
|
+
|
|
50
|
+
The default is `{ outputColorSpace: "linear-rec2020", format: "rgba16float" }`. Supported formats are `rgba16float`, `rgba8unorm` and `bgra8unorm`. sRGB output converts primaries, clips to [0, 1] and applies the sRGB transfer function on GPU; it does not add a photographic tone curve. Do not apply sRGB encoding again.
|
|
51
|
+
|
|
48
52
|
For TIFF:
|
|
49
53
|
|
|
50
54
|
```ts
|
|
@@ -61,15 +65,32 @@ Use separate passes for independent previews or exports. If passing an external
|
|
|
61
65
|
|
|
62
66
|
For file export, see the [Bun PNG/JPEG/BMP conversion example](docs/conversion.md).
|
|
63
67
|
|
|
68
|
+
|
|
69
|
+
## Minimal website
|
|
70
|
+
|
|
71
|
+
`web/` is a fullscreen RAW viewer with no app shader or rendering framework. It currently uses a local link: build the library and run `bun link` at the repository root, then `cd web && bun install && bun run dev`. After publishing the new API, pin that npm version in `web/package.json` for standalone deployment. Build with `bun run build` and serve `web/dist/` over HTTPS. Files stay in the browser. With the dev server running, `bun run test:web` from the repository root checks loading through Vite.
|
|
72
|
+
|
|
73
|
+
## API contract and support
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
- Default RAW and decoded TIFF output is linear Rec.2020/D65 `rgba16float`, with no display tone curve. HDR values can exceed 1. RAW passes can instead output encoded sRGB for display. TIFF alpha is straight. Camera JPEG previews apply their own rendering and are not pixel references for this output.
|
|
78
|
+
- `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.
|
|
79
|
+
- 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.
|
|
80
|
+
- 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.
|
|
81
|
+
- 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.
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
64
85
|
## Limitations and planned work
|
|
65
86
|
|
|
66
87
|
**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
88
|
|
|
68
89
|
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
90
|
|
|
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.
|
|
91
|
+
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
92
|
|
|
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
|
|
93
|
+
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
94
|
|
|
74
95
|
## Benchmark
|
|
75
96
|
|
|
@@ -103,7 +124,7 @@ The native build downloads checksum-pinned dependencies and caches objects in `.
|
|
|
103
124
|
|
|
104
125
|
`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
126
|
|
|
106
|
-
Publishing runs in GitHub Actions. Merge the version change into `main`, then publish a GitHub
|
|
127
|
+
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
128
|
|
|
108
129
|
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
130
|
|
package/dist/develop/gpu.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { DevelopOptions, RawPixels } from "../types";
|
|
1
|
+
import type { DevelopOptions, DevelopPassOptions, RawPixels } from "../types";
|
|
2
2
|
export declare function createPipeline(device: GPUDevice): Promise<{
|
|
3
3
|
sensor: GPURenderPipeline;
|
|
4
4
|
camera: GPURenderPipeline;
|
|
5
5
|
refine: GPURenderPipeline;
|
|
6
|
+
get(entryPoint: string, options: DevelopPassOptions): GPURenderPipeline;
|
|
6
7
|
}>;
|
|
7
8
|
/** Owns the sensor texture and immutable uniforms; each pass owns its calibration buffer. */
|
|
8
9
|
export declare function createGpuSource(device: GPUDevice, pixels: RawPixels, pipeline: Awaited<ReturnType<typeof createPipeline>>): {
|
|
@@ -20,7 +21,7 @@ export declare function createGpuSource(device: GPUDevice, pixels: RawPixels, pi
|
|
|
20
21
|
whiteBalanceOrigin?: "as-shot" | "daylight";
|
|
21
22
|
};
|
|
22
23
|
size: [number, number];
|
|
23
|
-
createDevelopPass(): {
|
|
24
|
+
createDevelopPass(options?: DevelopPassOptions): {
|
|
24
25
|
render(options: DevelopOptions): void;
|
|
25
26
|
dispose: () => void;
|
|
26
27
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { LoadOptions } from "./types";
|
|
2
|
-
export type { Calibration, DevelopOptions, LoadOptions, RawMetadata, WhiteBalance, } from "./types";
|
|
2
|
+
export type { Calibration, DevelopOptions, DevelopPassOptions, LoadOptions, RawMetadata, WhiteBalance, } from "./types";
|
|
3
3
|
/** Reuses a pipeline on the consumer's device. Each load owns an independent worker and source. */
|
|
4
4
|
export declare function createRawDecoder(device: GPUDevice): {
|
|
5
5
|
load(file: Blob, { signal }?: LoadOptions): Promise<{
|
|
@@ -17,7 +17,7 @@ export declare function createRawDecoder(device: GPUDevice): {
|
|
|
17
17
|
whiteBalanceOrigin?: "as-shot" | "daylight";
|
|
18
18
|
};
|
|
19
19
|
size: [number, number];
|
|
20
|
-
createDevelopPass(): {
|
|
20
|
+
createDevelopPass(options?: import("./types").DevelopPassOptions): {
|
|
21
21
|
render(options: import("./types").DevelopOptions): void;
|
|
22
22
|
dispose: () => void;
|
|
23
23
|
};
|
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) =>
|
|
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
|
}) => {
|
|
@@ -73,9 +90,24 @@ function createSession(compiled2) {
|
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
// src/develop/develop.wgsl
|
|
76
|
-
var develop_default = `// Sensor samples in,
|
|
93
|
+
var develop_default = `// Sensor samples in, linear Rec.2020 or display sRGB out: normalize, white balance,
|
|
77
94
|
// demosaic when needed, correct vignetting, convert color and apply exposure.
|
|
78
95
|
|
|
96
|
+
// Specialization removes display conversion from the default editor pipeline.
|
|
97
|
+
override outputSrgb: bool = false;
|
|
98
|
+
|
|
99
|
+
fn output(color: vec3f) -> vec4f {
|
|
100
|
+
if (!outputSrgb) { return vec4f(color, 1.0); }
|
|
101
|
+
let matrix = mat3x3f(
|
|
102
|
+
1.660491, -0.124550, -0.018151,
|
|
103
|
+
-0.587641, 1.132900, -0.100579,
|
|
104
|
+
-0.072850, -0.008349, 1.118730,
|
|
105
|
+
);
|
|
106
|
+
let rgb = clamp(matrix * color, vec3f(0.0), vec3f(1.0));
|
|
107
|
+
let encoded = select(1.055 * pow(rgb, vec3f(1.0 / 2.4)) - 0.055, 12.92 * rgb, rgb <= vec3f(0.0031308));
|
|
108
|
+
return vec4f(encoded, 1.0);
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
struct Sensor {
|
|
80
112
|
black: vec4f,
|
|
81
113
|
patternSize: vec4u,
|
|
@@ -201,7 +233,7 @@ fn demosaicPattern(p: vec2i, size: vec2i) -> vec3f {
|
|
|
201
233
|
let k = vignette.coefficients;
|
|
202
234
|
let gain = 1.0 + r2 * (k.x + r2 * (k.y + r2 * (k.z + r2 * (k.w + r2 * vignette.last))));
|
|
203
235
|
|
|
204
|
-
return
|
|
236
|
+
return output(calibration.matrix * camera * gain * calibration.exposure);
|
|
205
237
|
}
|
|
206
238
|
|
|
207
239
|
@group(0) @binding(5) var cameraSource: texture_2d<f32>;
|
|
@@ -244,7 +276,7 @@ fn demosaicPattern(p: vec2i, size: vec2i) -> vec3f {
|
|
|
244
276
|
// Cached X-Trans camera RGB: subsequent edits only apply per-pixel linear transforms.
|
|
245
277
|
@fragment fn fs_camera(@builtin(position) position: vec4f) -> @location(0) vec4f {
|
|
246
278
|
let camera = textureLoad(cameraSource, vec2i(position.xy), 0).rgb;
|
|
247
|
-
return
|
|
279
|
+
return output(calibration.matrix * (camera * calibration.gains) * calibration.exposure);
|
|
248
280
|
}
|
|
249
281
|
|
|
250
282
|
// One triangle covering the whole destination.
|
|
@@ -257,20 +289,46 @@ fn demosaicPattern(p: vec2i, size: vec2i) -> vec3f {
|
|
|
257
289
|
// src/develop/gpu.ts
|
|
258
290
|
async function createPipeline(device) {
|
|
259
291
|
const module = device.createShaderModule({ code: develop_default });
|
|
260
|
-
|
|
261
|
-
|
|
292
|
+
const pipelines = new Map;
|
|
293
|
+
function descriptor(entryPoint, options = {}) {
|
|
294
|
+
const { format = "rgba16float", outputColorSpace = "linear-rec2020" } = options;
|
|
295
|
+
if (!["rgba16float", "rgba8unorm", "bgra8unorm"].includes(format)) {
|
|
296
|
+
throw Error("RAW output format must be rgba16float, rgba8unorm or bgra8unorm.");
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
262
299
|
layout: "auto",
|
|
263
300
|
vertex: { module, entryPoint: "vs_main" },
|
|
264
|
-
fragment: {
|
|
301
|
+
fragment: {
|
|
302
|
+
module,
|
|
303
|
+
entryPoint,
|
|
304
|
+
targets: [{ format }],
|
|
305
|
+
constants: { outputSrgb: Number(outputColorSpace === "srgb") }
|
|
306
|
+
},
|
|
265
307
|
primitive: { topology: "triangle-list" }
|
|
266
|
-
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function key(entryPoint, options = {}) {
|
|
311
|
+
return `${entryPoint}:${options.format ?? "rgba16float"}:${options.outputColorSpace ?? "linear-rec2020"}`;
|
|
267
312
|
}
|
|
268
|
-
const [sensor, camera, refine] = await Promise.all([
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return {
|
|
313
|
+
const [sensor, camera, refine] = await Promise.all(["fs_main", "fs_camera", "fs_refine"].map(async (entryPoint) => {
|
|
314
|
+
const pipeline = await device.createRenderPipelineAsync(descriptor(entryPoint));
|
|
315
|
+
pipelines.set(key(entryPoint), pipeline);
|
|
316
|
+
return pipeline;
|
|
317
|
+
}));
|
|
318
|
+
return {
|
|
319
|
+
sensor,
|
|
320
|
+
camera,
|
|
321
|
+
refine,
|
|
322
|
+
get(entryPoint, options) {
|
|
323
|
+
const id = key(entryPoint, options);
|
|
324
|
+
let pipeline = pipelines.get(id);
|
|
325
|
+
if (!pipeline) {
|
|
326
|
+
pipeline = device.createRenderPipeline(descriptor(entryPoint, options));
|
|
327
|
+
pipelines.set(id, pipeline);
|
|
328
|
+
}
|
|
329
|
+
return pipeline;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
274
332
|
}
|
|
275
333
|
function createUniform(device, data) {
|
|
276
334
|
const buffer = device.createBuffer({
|
|
@@ -329,7 +387,7 @@ function createGpuSource(device, pixels, pipeline) {
|
|
|
329
387
|
texture.destroy();
|
|
330
388
|
cameraTexture?.destroy();
|
|
331
389
|
}
|
|
332
|
-
function createPass(renderPipeline, entries) {
|
|
390
|
+
function createPass(renderPipeline, entries, format2 = "rgba16float") {
|
|
333
391
|
if (closed) {
|
|
334
392
|
throw Error("RAW source is closed.");
|
|
335
393
|
}
|
|
@@ -355,8 +413,8 @@ function createGpuSource(device, pixels, pipeline) {
|
|
|
355
413
|
throw Error("RAW development pass is closed.");
|
|
356
414
|
}
|
|
357
415
|
const { destination, calibration, exposure = 0 } = options;
|
|
358
|
-
if (destination.format !==
|
|
359
|
-
throw Error(
|
|
416
|
+
if (destination.format !== format2 || destination.width !== size[0] || destination.height !== size[1]) {
|
|
417
|
+
throw Error(`RAW destination must be a ${format2} texture matching the oriented source size.`);
|
|
360
418
|
}
|
|
361
419
|
calibrationData.set(calibration.gains);
|
|
362
420
|
calibrationData[3] = 2 ** exposure;
|
|
@@ -460,13 +518,8 @@ function createGpuSource(device, pixels, pipeline) {
|
|
|
460
518
|
texture,
|
|
461
519
|
metadata,
|
|
462
520
|
size,
|
|
463
|
-
createDevelopPass() {
|
|
464
|
-
|
|
465
|
-
return createPass(pipeline.camera, [
|
|
466
|
-
{ binding: 5, resource: cameraTexture.createView() }
|
|
467
|
-
]);
|
|
468
|
-
}
|
|
469
|
-
return createPass(pipeline.sensor, sensorEntries);
|
|
521
|
+
createDevelopPass(options = {}) {
|
|
522
|
+
return createPass(pipeline.get(cameraTexture ? "fs_camera" : "fs_main", options), cameraTexture ? [{ binding: 5, resource: cameraTexture.createView() }] : sensorEntries, options.format);
|
|
470
523
|
},
|
|
471
524
|
dispose
|
|
472
525
|
};
|
|
@@ -539,7 +592,9 @@ fn oriented(p: vec2u) -> vec2u {
|
|
|
539
592
|
}
|
|
540
593
|
}
|
|
541
594
|
|
|
542
|
-
|
|
595
|
+
override workgroupRows: u32 = 16;
|
|
596
|
+
|
|
597
|
+
@compute @workgroup_size(16, workgroupRows) fn main(@builtin(global_invocation_id) id: vec3u) {
|
|
543
598
|
if id.x >= params.width || id.y >= params.rows { return; }
|
|
544
599
|
let offset = id.y * params.rowBytes + id.x * params.channels * params.bytes;
|
|
545
600
|
|
|
@@ -568,9 +623,14 @@ function createFilledBuffer(device, data, usage) {
|
|
|
568
623
|
usage,
|
|
569
624
|
mappedAtCreation: true
|
|
570
625
|
});
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
626
|
+
try {
|
|
627
|
+
new Uint8Array(result.getMappedRange()).set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
628
|
+
result.unmap();
|
|
629
|
+
return result;
|
|
630
|
+
} catch (error) {
|
|
631
|
+
result.destroy();
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
574
634
|
}
|
|
575
635
|
function createParams(pixels, startRow, rows) {
|
|
576
636
|
const params = new ArrayBuffer(96);
|
|
@@ -585,14 +645,16 @@ function createParams(pixels, startRow, rows) {
|
|
|
585
645
|
}
|
|
586
646
|
return new Uint8Array(params);
|
|
587
647
|
}
|
|
588
|
-
async function uploadTiff(device, pixels) {
|
|
648
|
+
async function uploadTiff(device, pixels, signal) {
|
|
649
|
+
const workgroupRows = Math.min(16, Math.floor(device.limits.maxComputeInvocationsPerWorkgroup / 16));
|
|
589
650
|
let pending = pipelines.get(device);
|
|
590
651
|
if (!pending) {
|
|
591
652
|
pending = device.createComputePipelineAsync({
|
|
592
653
|
layout: "auto",
|
|
593
654
|
compute: {
|
|
594
655
|
module: device.createShaderModule({ code: upload_default }),
|
|
595
|
-
entryPoint: "main"
|
|
656
|
+
entryPoint: "main",
|
|
657
|
+
constants: { workgroupRows }
|
|
596
658
|
}
|
|
597
659
|
}).catch((error) => {
|
|
598
660
|
pipelines.delete(device);
|
|
@@ -601,6 +663,7 @@ async function uploadTiff(device, pixels) {
|
|
|
601
663
|
pipelines.set(device, pending);
|
|
602
664
|
}
|
|
603
665
|
const pipeline = await pending;
|
|
666
|
+
signal?.throwIfAborted();
|
|
604
667
|
const { data, metadata, color } = pixels;
|
|
605
668
|
const [width, height, , , , orientation, , , rowBytes] = metadata;
|
|
606
669
|
const size = orientation >= 5 ? [height, width] : [width, height];
|
|
@@ -616,13 +679,15 @@ async function uploadTiff(device, pixels) {
|
|
|
616
679
|
format: "rgba16float",
|
|
617
680
|
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_SRC
|
|
618
681
|
});
|
|
619
|
-
|
|
682
|
+
let curves;
|
|
620
683
|
try {
|
|
684
|
+
curves = createFilledBuffer(device, color.table, GPUBufferUsage.STORAGE);
|
|
621
685
|
for (let y = 0;y < height; y += rowsPerBand) {
|
|
622
686
|
const rows = Math.min(rowsPerBand, height - y);
|
|
623
687
|
const source = createFilledBuffer(device, data.subarray(y * rowBytes, (y + rows) * rowBytes), GPUBufferUsage.STORAGE);
|
|
624
|
-
|
|
688
|
+
let params;
|
|
625
689
|
try {
|
|
690
|
+
params = createFilledBuffer(device, createParams(pixels, y, rows), GPUBufferUsage.UNIFORM);
|
|
626
691
|
const bindings = device.createBindGroup({
|
|
627
692
|
layout: pipeline.getBindGroupLayout(0),
|
|
628
693
|
entries: [
|
|
@@ -636,12 +701,12 @@ async function uploadTiff(device, pixels) {
|
|
|
636
701
|
const pass = encoder.beginComputePass();
|
|
637
702
|
pass.setPipeline(pipeline);
|
|
638
703
|
pass.setBindGroup(0, bindings);
|
|
639
|
-
pass.dispatchWorkgroups(Math.ceil(width / 16), Math.ceil(rows /
|
|
704
|
+
pass.dispatchWorkgroups(Math.ceil(width / 16), Math.ceil(rows / workgroupRows));
|
|
640
705
|
pass.end();
|
|
641
706
|
device.queue.submit([encoder.finish()]);
|
|
642
707
|
} finally {
|
|
643
708
|
source.destroy();
|
|
644
|
-
params
|
|
709
|
+
params?.destroy();
|
|
645
710
|
}
|
|
646
711
|
}
|
|
647
712
|
return { texture, size, dispose: () => texture.destroy() };
|
|
@@ -649,21 +714,20 @@ async function uploadTiff(device, pixels) {
|
|
|
649
714
|
texture.destroy();
|
|
650
715
|
throw error;
|
|
651
716
|
} finally {
|
|
652
|
-
curves
|
|
717
|
+
curves?.destroy();
|
|
653
718
|
}
|
|
654
719
|
}
|
|
655
720
|
|
|
656
721
|
// src/tiff/index.ts
|
|
657
|
-
async function decodeTiff(device, file, { signal } = {}) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
722
|
+
async function decodeTiff(device, file, { signal: requestedSignal } = {}) {
|
|
723
|
+
const signal = requestedSignal ? AbortSignal.any([requestedSignal, deviceSignal(device)]) : deviceSignal(device);
|
|
724
|
+
signal.throwIfAborted();
|
|
661
725
|
const worker = new Worker(new URL("./tiff-worker.js", import.meta.url), {
|
|
662
726
|
type: "module"
|
|
663
727
|
});
|
|
664
728
|
const aborted = Promise.withResolvers();
|
|
665
|
-
const abort = () => aborted.reject(signal
|
|
666
|
-
signal
|
|
729
|
+
const abort = () => aborted.reject(signal.reason);
|
|
730
|
+
signal.addEventListener("abort", abort);
|
|
667
731
|
try {
|
|
668
732
|
const module = await Promise.race([compileDecoder(), aborted.promise]);
|
|
669
733
|
const decoded = new Promise((resolve, reject) => {
|
|
@@ -682,9 +746,16 @@ async function decodeTiff(device, file, { signal } = {}) {
|
|
|
682
746
|
worker.postMessage({ file, module });
|
|
683
747
|
});
|
|
684
748
|
const pixels = await Promise.race([decoded, aborted.promise]);
|
|
685
|
-
|
|
749
|
+
const uploading = uploadTiff(device, pixels, signal).then((image) => {
|
|
750
|
+
if (signal.aborted) {
|
|
751
|
+
image.dispose();
|
|
752
|
+
throw signal.reason;
|
|
753
|
+
}
|
|
754
|
+
return image;
|
|
755
|
+
});
|
|
756
|
+
return await Promise.race([uploading, aborted.promise]);
|
|
686
757
|
} finally {
|
|
687
|
-
signal
|
|
758
|
+
signal.removeEventListener("abort", abort);
|
|
688
759
|
worker.terminate();
|
|
689
760
|
}
|
|
690
761
|
}
|
|
@@ -696,14 +767,16 @@ function createRawDecoder(device) {
|
|
|
696
767
|
let closed = false;
|
|
697
768
|
function dispose() {
|
|
698
769
|
closed = true;
|
|
770
|
+
lost.removeEventListener("abort", dispose);
|
|
699
771
|
for (const close of sources) {
|
|
700
772
|
close();
|
|
701
773
|
}
|
|
702
774
|
}
|
|
703
|
-
|
|
775
|
+
const lost = deviceSignal(device);
|
|
776
|
+
lost.addEventListener("abort", dispose, { once: true });
|
|
704
777
|
return {
|
|
705
778
|
async load(file, { signal } = {}) {
|
|
706
|
-
if (closed) {
|
|
779
|
+
if (closed || lost.aborted) {
|
|
707
780
|
throw Error("RAW decoder is closed.");
|
|
708
781
|
}
|
|
709
782
|
if (signal?.aborted) {
|
package/dist/tiff/color.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/tiff/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/tiff/upload.d.ts
CHANGED
|
@@ -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;
|
package/dist/tiff-worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
|
60
|
-
if (
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
table = sampleCurves(profile.curves);
|
|
142
|
+
throw Error("Malformed TIFF ICC transfer curve.");
|
|
119
143
|
}
|
|
120
144
|
return {
|
|
121
145
|
table,
|
package/dist/types.d.ts
CHANGED
|
@@ -41,6 +41,12 @@ export type LoadOptions = {
|
|
|
41
41
|
/** Aborting rejects a pending load with the signal's reason; a loaded source is unaffected. */
|
|
42
42
|
signal?: AbortSignal;
|
|
43
43
|
};
|
|
44
|
+
export type DevelopPassOptions = {
|
|
45
|
+
/** Default linear-rec2020 preserves HDR. srgb converts primaries, clips to [0, 1] and encodes sRGB. */
|
|
46
|
+
outputColorSpace?: "linear-rec2020" | "srgb";
|
|
47
|
+
/** rgba16float by default; rgba8unorm and bgra8unorm also supported for direct display. */
|
|
48
|
+
format?: GPUTextureFormat;
|
|
49
|
+
};
|
|
44
50
|
export type DevelopOptions = {
|
|
45
51
|
destination: GPUTexture;
|
|
46
52
|
calibration: Calibration;
|
package/docs/conversion.md
CHANGED
|
@@ -7,34 +7,19 @@ import { PNG } from "pngjs";
|
|
|
7
7
|
import { init, target } from "vgpu/node";
|
|
8
8
|
import { createRawDecoder } from "raw-webgpu";
|
|
9
9
|
|
|
10
|
-
function srgb(value: number) {
|
|
11
|
-
const x = Math.max(0, Math.min(1, value));
|
|
12
|
-
return Math.round(255 * (
|
|
13
|
-
x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055
|
|
14
|
-
));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
10
|
const gpu = await init();
|
|
18
11
|
const decoder = createRawDecoder(gpu.gpu);
|
|
19
12
|
try {
|
|
20
13
|
const source = await decoder.load(Bun.file("photo.dng"));
|
|
21
|
-
const output = target(gpu, { size: source.size, format: "
|
|
14
|
+
const output = target(gpu, { size: source.size, format: "rgba8unorm" });
|
|
22
15
|
try {
|
|
23
|
-
source.createDevelopPass().render({
|
|
16
|
+
source.createDevelopPass({ outputColorSpace: "srgb", format: "rgba8unorm" }).render({
|
|
24
17
|
destination: output.color.gpu,
|
|
25
18
|
calibration: source.calibration,
|
|
26
19
|
});
|
|
27
20
|
|
|
28
|
-
const pixels = await output.readFloats();
|
|
29
21
|
const image = new PNG({ width: source.size[0], height: source.size[1] });
|
|
30
|
-
|
|
31
|
-
const [r, g, b] = pixels.subarray(i, i + 3);
|
|
32
|
-
// Linear Rec.2020 → linear sRGB → encoded sRGB.
|
|
33
|
-
image.data[i] = srgb(1.660491 * r - 0.587641 * g - 0.072850 * b);
|
|
34
|
-
image.data[i + 1] = srgb(-0.124550 * r + 1.132900 * g - 0.008349 * b);
|
|
35
|
-
image.data[i + 2] = srgb(-0.018151 * r - 0.100579 * g + 1.118730 * b);
|
|
36
|
-
image.data[i + 3] = 255;
|
|
37
|
-
}
|
|
22
|
+
image.data.set(await output.read());
|
|
38
23
|
await Bun.write("photo.png", PNG.sync.write(image));
|
|
39
24
|
} finally {
|
|
40
25
|
output.color.dispose();
|
|
@@ -49,4 +34,4 @@ For JPEG, install `jpeg-js`, import its `encode` function and replace the final
|
|
|
49
34
|
|
|
50
35
|
Bun supplies the worker and local file-fetch APIs; Chromium is not required. Plain Node.js is not supported by this snippet. A working GPU backend is required; `vgpu/mock` cannot execute the development shader.
|
|
51
36
|
|
|
52
|
-
The example uses the initial white balance, develops on GPU, then reads back
|
|
37
|
+
The example uses the initial white balance, develops and converts to sRGB on GPU, then reads back the encoded bytes. Output is 8-bit SDR/sRGB with clipping and no photographic tone curve. File encoding belongs to the consuming application; the library does not include image encoders or a conversion helper.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "raw-webgpu",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Camera RAW, DNG and TIFF decoding with WebGPU development and interactive white balance.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"test": "bun test",
|
|
29
29
|
"test:gpu": "GPU=1 bun test",
|
|
30
30
|
"test:browser": "bun tests/browser.ts",
|
|
31
|
+
"test:web": "bun tests/web.ts",
|
|
31
32
|
"build:sdk": "bun build.ts && tsc --emitDeclarationOnly",
|
|
32
33
|
"test:tiff": "bun tests/tiff.browser.ts",
|
|
33
34
|
"benchmark:tiff": "bun tests/tiff.browser.ts --benchmark",
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
},
|
|
46
47
|
"publishConfig": {
|
|
47
48
|
"access": "public",
|
|
48
|
-
"tag": "
|
|
49
|
+
"tag": "latest",
|
|
49
50
|
"registry": "https://registry.npmjs.org/"
|
|
50
51
|
},
|
|
51
52
|
"keywords": [
|