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 +19 -6
- package/dist/decode/device.d.ts +2 -0
- package/dist/index.js +59 -22
- 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/package.json +2 -2
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
|
-
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
}) => {
|
|
@@ -539,7 +556,9 @@ fn oriented(p: vec2u) -> vec2u {
|
|
|
539
556
|
}
|
|
540
557
|
}
|
|
541
558
|
|
|
542
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
|
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
|
|
681
|
+
curves?.destroy();
|
|
653
682
|
}
|
|
654
683
|
}
|
|
655
684
|
|
|
656
685
|
// src/tiff/index.ts
|
|
657
|
-
async function decodeTiff(device, file, { signal } = {}) {
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
666
|
-
signal
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "raw-webgpu",
|
|
3
|
-
"version": "0.1.0
|
|
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": "
|
|
48
|
+
"tag": "latest",
|
|
49
49
|
"registry": "https://registry.npmjs.org/"
|
|
50
50
|
},
|
|
51
51
|
"keywords": [
|