bright-canvas 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 ADDED
@@ -0,0 +1,73 @@
1
+
2
+
3
+
4
+
5
+ # bright-canvas
6
+
7
+ **Brighten any canvas with proper HDR support. No flags required.**
8
+
9
+ **[View the demo here!](https://bright-canvas.netlify.app/demo/example.html)**
10
+
11
+ Sends a source canvas through an WebGPU canvas with extended tone mapping, enabling HDR brightness with minimal setup and no fighting with WebGPU.
12
+ Falls back to a plain 2D context when WebGPU and/or HDR is not available in the browser.
13
+
14
+ Supported since [Chrome 129](https://developer.chrome.com/blog/new-in-webgpu-129#hdr_support_with_canvas_tone_mapping_mode) and [iOS/Safari 26](https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes#WebGPU).
15
+
16
+ Firefox does not have HDR support for WebGPU canvases yet.
17
+
18
+ WebGPU will only work in a secure context or on localhost.
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ npm install bright-canvas
24
+ ```
25
+
26
+ ```js
27
+ import { initHdrCanvas } from 'bright-canvas';
28
+
29
+ // minimal usage
30
+ const hdr = await initHdrCanvas(srcCanvas, outCanvas);
31
+
32
+ // advanced usage
33
+ const hdr = await initHdrCanvas(srcCanvas, outCanvas, {
34
+ debug: true, // log messages to the console
35
+ brightness: 2, // initial brightness factor
36
+ sourceFloat16: false, // set true if srcCanvas has float16 backing
37
+ });
38
+
39
+ hdr.mode; // 'hdr' | 'sdr'
40
+ hdr.setBrightness(v); // 2 is relatively safe
41
+ hdr.render(); // call in frame function after drawing to srcCanvas
42
+ hdr.destroy(); // release WebGPU canvas resources
43
+ ```
44
+
45
+ See [demo/minimal.html](https://github.com/ethan-ou/bright-canvas/blob/main/demo/minimal.html) for a minimal example. [(web)](https://bright-canvas.netlify.app/demo/minimal.html)
46
+
47
+ ## Demo
48
+
49
+ WebGPU + ES modules in this demo require a secure context. When running this repo locally, run `local-web-server` to serve.
50
+
51
+ `local-web-server` serves a self-signed cert by default and will show a warning in browser.
52
+ Bypass by clicking Show Details/Advanced then Proceed/Visit ...
53
+
54
+ ```
55
+ npx local-web-server --https
56
+ # then open to /demo/hdr-2d-canvas-module.html
57
+ ```
58
+
59
+ ## Notes
60
+
61
+ - Brightness multiplies the color values from the source canvas, but does not increase bit depth with default canvas/bright-canvas options, and may result in muted colors.
62
+ - When using canvases with higher bit depths (ex. `colorType: 'float16'` as 2d canvas option), set `sourceFloat16` to true to properly map these values.
63
+ - `colorType` is only supported in Chrome at the moment.
64
+ - Using in combination with `colorSpace: 'display-p3'` is recommended.
65
+ - Vibrant colors may only show up on Chrome in MacOS. See [demo/minimal-float16.html](https://github.com/ethan-ou/bright-canvas/blob/main/demo/minimal-float16.html) for a minimal example with float16 and display-p3 colors. [(web)](https://bright-canvas.netlify.app/demo/minimal-float16.html)
66
+ - Call `hdr.render()` in the same frame you draw to the source canvas.
67
+ - Source dimensions are captured at init; resizing the source canvas afterward is not handled yet.
68
+
69
+ ## Disclosure
70
+
71
+ Claude Fable 5 was used to develop bright-canvas.js and demo/example.html with minimal editing.
72
+ Minimal examples and this README.md were written by me.
73
+ WebGPU code was adapted from this webgpu-samples particles demo: https://webgpu.github.io/webgpu-samples/?sample=particles
@@ -0,0 +1,241 @@
1
+ // bright-canvas.js
2
+ //
3
+ // Draws the contents of a source canvas onto an output canvas using a
4
+ // WebGPU rgba16float context with extended tone mapping (HDR) when the
5
+ // browser and display support it, or a plain 2D drawImage fallback (SDR).
6
+ //
7
+ // Usage:
8
+ // const hdr = await initHdrCanvas(srcCanvas, outCanvas, options);
9
+ // hdr.mode // 'hdr' | 'sdr'
10
+ // hdr.setBrightness(2.5) // HDR only; no-op in SDR mode
11
+ // hdr.render() // call each frame after drawing to srcCanvas
12
+ // hdr.destroy() // release all GPU resources and references;
13
+ // // render/setBrightness become no-ops
14
+ //
15
+ // Options:
16
+ // debug (default false) - log messages to the console
17
+ // brightness (default 2) - initial brightness factor; can be
18
+ // changed later with setBrightness()
19
+ // sourceFloat16 (default false) - set true when srcCanvas has float16
20
+ // backing (e.g. colorType: 'float16')
21
+ // so extended-range (>1.0) values
22
+ // survive the copy. Not auto-detected:
23
+ // probing would call getContext() on
24
+ // the source canvas, which locks a
25
+ // fresh canvas to that context type.
26
+
27
+ export async function initHdrCanvas(
28
+ srcCanvas,
29
+ outCanvas,
30
+ { debug = false, brightness = 2, sourceFloat16 = false } = {},
31
+ ) {
32
+ const log = debug ? (msg) => console.log("[bright-canvas]", msg) : () => {};
33
+
34
+ const W = srcCanvas.width;
35
+ const H = srcCanvas.height;
36
+
37
+ const srcTextureFormat = sourceFloat16 ? "rgba16float" : "rgba8unorm";
38
+
39
+ // Capability check: WebGPU + HDR display
40
+ const hdrMediaQuery = window.matchMedia("(dynamic-range: high)");
41
+ let device = null;
42
+ if (!navigator.gpu) {
43
+ log("⚠️ WebGPU is not available - falling back to a 2D canvas (SDR).");
44
+ } else if (!hdrMediaQuery.matches) {
45
+ log("⚠️ Display isn't HDR-compatible - falling back to a 2D canvas (SDR).");
46
+ } else {
47
+ const adapter = await navigator.gpu.requestAdapter();
48
+ device = (await adapter?.requestDevice()) ?? null;
49
+ if (!device) {
50
+ log(
51
+ "⚠️ Failed to get a WebGPU device - falling back to a 2D canvas (SDR).",
52
+ );
53
+ }
54
+ }
55
+
56
+ // Probe rgba16float + extended tone mapping (Firefox supports WebGPU
57
+ // but not rgba16float canvases). Probing uses a throwaway canvas so
58
+ // out-canvas can still get a '2d' context if we end up falling back.
59
+ let presentationFormat = null;
60
+ if (device) {
61
+ const probeContext = document.createElement("canvas").getContext("webgpu");
62
+ if (probeContext) {
63
+ try {
64
+ probeContext.configure({
65
+ device,
66
+ format: "rgba16float",
67
+ toneMapping: { mode: "extended" },
68
+ });
69
+ presentationFormat = "rgba16float";
70
+ } catch (e) {
71
+ log(`⚠️ Canvas format 'rgba16float' not supported: ${e.message}`);
72
+ }
73
+ probeContext.unconfigure();
74
+ }
75
+ if (!presentationFormat) {
76
+ log(
77
+ "⚠️ No usable WebGPU canvas format - falling back to a 2D canvas (SDR).",
78
+ );
79
+ device = null;
80
+ }
81
+ }
82
+
83
+ // Fallback path: plain 2D canvas, drawImage from the source canvas.
84
+ if (!device) {
85
+ let outCtx = outCanvas.getContext("2d");
86
+ return {
87
+ mode: "sdr",
88
+ setBrightness() {}, // no-op in SDR mode
89
+ render() {
90
+ if (!outCtx) return;
91
+ outCtx.drawImage(srcCanvas, 0, 0, outCanvas.width, outCanvas.height);
92
+ },
93
+ destroy() {
94
+ outCtx = null;
95
+ },
96
+ };
97
+ }
98
+
99
+ // WebGPU path: rgba16float + extended tone mapping (HDR)
100
+ let context = outCanvas.getContext("webgpu");
101
+ context.configure({
102
+ device,
103
+ format: presentationFormat,
104
+ toneMapping: { mode: "extended" },
105
+ });
106
+ log(`ℹ️ WebGPU canvas format: ${presentationFormat}`);
107
+ log(`ℹ️ Source texture format: ${srcTextureFormat}`);
108
+
109
+ if ("getConfiguration" in GPUCanvasContext.prototype) {
110
+ const mode = context.getConfiguration()?.toneMapping?.mode;
111
+ if (mode !== "extended") {
112
+ log(
113
+ "⚠️ Browser doesn't support HDR canvas, fell back to '" + mode + "').",
114
+ );
115
+ } else {
116
+ log("✅ HDR canvas enabled.");
117
+ }
118
+ } else {
119
+ log("ℹ️ getConfiguration() unavailable.");
120
+ }
121
+
122
+ // Bridge: copy the 2D canvas into a texture each frame, then render
123
+ // it fullscreen multiplied by the brightness factor. An 8-bit 2D
124
+ // canvas only holds 0–1 values, so the multiply is what pushes
125
+ // highlights into HDR range.
126
+ let srcTexture = device.createTexture({
127
+ size: [W, H],
128
+ format: srcTextureFormat,
129
+ usage:
130
+ GPUTextureUsage.TEXTURE_BINDING |
131
+ GPUTextureUsage.COPY_DST |
132
+ GPUTextureUsage.RENDER_ATTACHMENT,
133
+ });
134
+
135
+ const shaderModule = device.createShaderModule({
136
+ code: /* wgsl */ `
137
+ @group(0) @binding(0) var samp: sampler;
138
+ @group(0) @binding(1) var tex: texture_2d<f32>;
139
+ @group(0) @binding(2) var<uniform> brightness: f32;
140
+
141
+ struct VSOut {
142
+ @builtin(position) pos: vec4f,
143
+ @location(0) uv: vec2f,
144
+ };
145
+
146
+ @vertex
147
+ fn vs(@builtin(vertex_index) i: u32) -> VSOut {
148
+ // Fullscreen triangle
149
+ let pos = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
150
+ var out: VSOut;
151
+ out.pos = vec4f(pos[i], 0, 1);
152
+ out.uv = pos[i] * vec2f(0.5, -0.5) + 0.5;
153
+ return out;
154
+ }
155
+
156
+ @fragment
157
+ fn fs(in: VSOut) -> @location(0) vec4f {
158
+ let c = textureSample(tex, samp, in.uv);
159
+ return vec4f(c.rgb * brightness, 1.0);
160
+ }
161
+ `,
162
+ });
163
+
164
+ let pipeline = device.createRenderPipeline({
165
+ layout: "auto",
166
+ vertex: { module: shaderModule, entryPoint: "vs" },
167
+ fragment: {
168
+ module: shaderModule,
169
+ entryPoint: "fs",
170
+ targets: [{ format: presentationFormat }],
171
+ },
172
+ primitive: { topology: "triangle-list" },
173
+ });
174
+
175
+ let sampler = device.createSampler({
176
+ magFilter: "linear",
177
+ minFilter: "linear",
178
+ });
179
+ let brightnessBuffer = device.createBuffer({
180
+ size: 4,
181
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
182
+ });
183
+ device.queue.writeBuffer(brightnessBuffer, 0, new Float32Array([brightness]));
184
+
185
+ let bindGroup = device.createBindGroup({
186
+ layout: pipeline.getBindGroupLayout(0),
187
+ entries: [
188
+ { binding: 0, resource: sampler },
189
+ { binding: 1, resource: srcTexture.createView() },
190
+ { binding: 2, resource: { buffer: brightnessBuffer } },
191
+ ],
192
+ });
193
+
194
+ return {
195
+ mode: "hdr",
196
+ setBrightness(value) {
197
+ if (!device) return;
198
+ device.queue.writeBuffer(brightnessBuffer, 0, new Float32Array([value]));
199
+ },
200
+ render() {
201
+ if (!device) return;
202
+ device.queue.copyExternalImageToTexture(
203
+ { source: srcCanvas },
204
+ { texture: srcTexture },
205
+ [W, H],
206
+ );
207
+
208
+ const commandEncoder = device.createCommandEncoder();
209
+ const passEncoder = commandEncoder.beginRenderPass({
210
+ colorAttachments: [
211
+ {
212
+ view: context.getCurrentTexture().createView(),
213
+ clearValue: [0, 0, 0, 1],
214
+ loadOp: "clear",
215
+ storeOp: "store",
216
+ },
217
+ ],
218
+ });
219
+ passEncoder.setPipeline(pipeline);
220
+ passEncoder.setBindGroup(0, bindGroup);
221
+ passEncoder.draw(3);
222
+ passEncoder.end();
223
+ device.queue.submit([commandEncoder.finish()]);
224
+ },
225
+ destroy() {
226
+ if (!device) return;
227
+ srcTexture.destroy();
228
+ brightnessBuffer.destroy();
229
+ context.unconfigure();
230
+ device.destroy();
231
+ srcTexture = null;
232
+ brightnessBuffer = null;
233
+ bindGroup = null;
234
+ pipeline = null;
235
+ sampler = null;
236
+ context = null;
237
+ device = null;
238
+ log("ℹ️ HDR canvas destroyed.");
239
+ },
240
+ };
241
+ }
package/bun.lock ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "devDependencies": {
7
+ "@rollup/plugin-json": "^6.1.0",
8
+ "@rollup/plugin-terser": "^1.0.0",
9
+ "rollup": "^4.62.2",
10
+ },
11
+ },
12
+ },
13
+ "packages": {
14
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
15
+
16
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
17
+
18
+ "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
19
+
20
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
21
+
22
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
23
+
24
+ "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
25
+
26
+ "@rollup/plugin-terser": ["@rollup/plugin-terser@1.0.0", "", { "dependencies": { "serialize-javascript": "^7.0.3", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ=="],
27
+
28
+ "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="],
29
+
30
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
31
+
32
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
33
+
34
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
35
+
36
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
37
+
38
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
39
+
40
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
41
+
42
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
43
+
44
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
45
+
46
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
47
+
48
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
49
+
50
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
51
+
52
+ "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
53
+
54
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
55
+
56
+ "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
57
+
58
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
59
+
60
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
61
+
62
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
63
+
64
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
65
+
66
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
67
+
68
+ "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
69
+
70
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
71
+
72
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
73
+
74
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
75
+
76
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
77
+
78
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
79
+
80
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
81
+
82
+ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
83
+
84
+ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
85
+
86
+ "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
87
+
88
+ "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
89
+
90
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
91
+
92
+ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
93
+
94
+ "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
95
+
96
+ "serialize-javascript": ["serialize-javascript@7.0.7", "", {}, "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g=="],
97
+
98
+ "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="],
99
+
100
+ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
101
+
102
+ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
103
+
104
+ "terser": ["terser@5.49.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA=="],
105
+ }
106
+ }
@@ -0,0 +1,177 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>2D canvas animation to WebGPU HDR canvas (module)</title>
8
+ <style>
9
+ :root {
10
+ color-scheme: light dark;
11
+ }
12
+
13
+ html,
14
+ body {
15
+ margin: 0;
16
+ height: 100%;
17
+ display: flex;
18
+ place-content: center center;
19
+ align-items: center;
20
+ flex-direction: column;
21
+ gap: 12px;
22
+ font-family: system-ui, sans-serif;
23
+ }
24
+
25
+ .row {
26
+ display: flex;
27
+ flex-wrap: wrap;
28
+ gap: 12px;
29
+ align-items: flex-end;
30
+ max-width: 100%;
31
+ }
32
+
33
+ canvas {
34
+ display: block;
35
+ }
36
+
37
+ #out-canvas {
38
+ width: 600px;
39
+ height: 600px;
40
+ max-width: 100%;
41
+ }
42
+
43
+ #src-canvas {
44
+ width: 200px;
45
+ height: 200px;
46
+ }
47
+
48
+ .label {
49
+ font-size: 12px;
50
+ text-align: center;
51
+ margin-top: 4px;
52
+ }
53
+
54
+ #mode-summary {
55
+ font-size: 14px;
56
+ text-align: center;
57
+ }
58
+ </style>
59
+ </head>
60
+
61
+ <body>
62
+ <div class="row">
63
+ <div>
64
+ <canvas id="out-canvas"></canvas>
65
+ <div class="label" id="out-label">WebGPU canvas (HDR)</div>
66
+ </div>
67
+ <div>
68
+ <canvas id="src-canvas" width="600" height="600"></canvas>
69
+ <div class="label">Source 2D canvas (SDR)</div>
70
+ </div>
71
+ </div>
72
+ <div>
73
+ <label><input id="show-chart" type="checkbox"> Show test_chart.png</label>
74
+ </div>
75
+ <div id="brightness-control" hidden>
76
+ <label for="brightness">Brightness factor: <span id="brightness-value">2.0</span></label>
77
+ <input id="brightness" type="range" min="1" max="4" step="0.1" value="2"
78
+ style="width: 300px; vertical-align: middle;">
79
+ </div>
80
+ <div id="mode-summary">Initializing…</div>
81
+ <script type="module">
82
+ import { initHdrCanvas } from '/bright-canvas.js';
83
+ // To use NPM package, move file to builder like Vite, comment out the above line and uncomment the below line
84
+ // import { initHdrCanvas } from 'bright-canvas';
85
+
86
+ // Source: plain 2D canvas with a basic animation (SDR, 0–1 values)
87
+ const srcCanvas = document.getElementById('src-canvas');
88
+ const ctx2d = srcCanvas.getContext('2d');
89
+ const W = srcCanvas.width;
90
+ const H = srcCanvas.height;
91
+
92
+ const ball = { x: W / 2, y: H / 3, vx: 260, vy: 200, r: 46 };
93
+
94
+ function draw2d(dt, t) {
95
+ // Solid black background
96
+ ctx2d.fillStyle = 'black';
97
+ ctx2d.fillRect(0, 0, W, H);
98
+
99
+ // Bounce
100
+ ball.x += ball.vx * dt;
101
+ ball.y += ball.vy * dt;
102
+ if (ball.x < ball.r || ball.x > W - ball.r) { ball.vx *= -1; ball.x = Math.max(ball.r, Math.min(W - ball.r, ball.x)); }
103
+ if (ball.y < ball.r || ball.y > H - ball.r) { ball.vy *= -1; ball.y = Math.max(ball.r, Math.min(H - ball.r, ball.y)); }
104
+
105
+ // Glowing ball - white core, colored halo
106
+ const halo = ctx2d.createRadialGradient(ball.x, ball.y, 0, ball.x, ball.y, ball.r * 2.2);
107
+ halo.addColorStop(0, 'white');
108
+ halo.addColorStop(0.35, `hsl(${(t * 40) % 360} 100% 60%)`);
109
+ halo.addColorStop(1, 'transparent');
110
+ ctx2d.fillStyle = halo;
111
+ ctx2d.beginPath();
112
+ ctx2d.arc(ball.x, ball.y, ball.r * 2.2, 0, Math.PI * 2);
113
+ ctx2d.fill();
114
+ }
115
+
116
+ // Optional static image source (aspect-fit, centered)
117
+ const showChart = document.getElementById('show-chart');
118
+ const chartImg = new Image();
119
+ chartImg.src = './test_chart.png';
120
+
121
+ function drawChart() {
122
+ ctx2d.fillStyle = 'black';
123
+ ctx2d.fillRect(0, 0, W, H);
124
+ const scale = Math.min(W / chartImg.width, H / chartImg.height);
125
+ const w = chartImg.width * scale;
126
+ const h = chartImg.height * scale;
127
+ ctx2d.drawImage(chartImg, (W - w) / 2, (H - h) / 2, w, h);
128
+ }
129
+
130
+ // Output canvas: HDR via the module, or its built-in SDR fallback
131
+ const outCanvas = document.getElementById('out-canvas');
132
+ outCanvas.width = outCanvas.clientWidth * window.devicePixelRatio;
133
+ outCanvas.height = outCanvas.clientHeight * window.devicePixelRatio;
134
+
135
+ const hdr = await initHdrCanvas(srcCanvas, outCanvas, { debug: true, brightness: 2 });
136
+
137
+ const modeSummary = document.getElementById('mode-summary');
138
+ if (hdr.mode === 'hdr') {
139
+ modeSummary.textContent = '✅ HDR mode (WebGPU)';
140
+ document.getElementById('brightness-control').hidden = false;
141
+
142
+ // Brightness slider (handled here in the HTML)
143
+ const brightnessSlider = document.getElementById('brightness');
144
+ const brightnessValue = document.getElementById('brightness-value');
145
+ const updateBrightness = () => {
146
+ const brightness = parseFloat(brightnessSlider.value);
147
+ brightnessValue.textContent = brightness.toFixed(1);
148
+ hdr.setBrightness(brightness);
149
+ };
150
+ brightnessSlider.addEventListener('input', updateBrightness);
151
+ updateBrightness();
152
+ } else {
153
+ document.getElementById('out-label').textContent = 'Fallback 2D canvas (SDR)';
154
+ modeSummary.textContent = '❌ SDR mode (2D canvas fallback)';
155
+ }
156
+
157
+ // Frame loop
158
+ let lastT = 0;
159
+ function frame(timeMs) {
160
+ const t = timeMs / 1000;
161
+ const dt = Math.min(t - lastT, 0.05);
162
+ lastT = t;
163
+
164
+ if (showChart.checked && chartImg.complete && chartImg.naturalWidth > 0) {
165
+ drawChart();
166
+ } else {
167
+ draw2d(dt, t);
168
+ }
169
+ hdr.render();
170
+
171
+ requestAnimationFrame(frame);
172
+ }
173
+ requestAnimationFrame(frame);
174
+ </script>
175
+ </body>
176
+
177
+ </html>
@@ -0,0 +1,58 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>bright-canvas</title>
8
+ </head>
9
+
10
+ <body>
11
+ <canvas id="out-canvas" width="400" height="400"></canvas>
12
+ <canvas id="src-canvas" width="400" height="400"></canvas>
13
+ <script type="module">
14
+ import { initHdrCanvas } from '/bright-canvas.js';
15
+ // To use NPM package, move file to builder like Vite, comment out the above line and uncomment the below line
16
+ // import { initHdrCanvas } from 'bright-canvas';
17
+
18
+ const canvas = document.getElementById('src-canvas');
19
+ const ctx = canvas.getContext('2d', { colorSpace: 'display-p3', colorType: 'float16' });
20
+ const outCanvas = document.getElementById('out-canvas');
21
+ const hdr = await initHdrCanvas(canvas, outCanvas, {
22
+ debug: true,
23
+ brightness: 2,
24
+ sourceFloat16: true
25
+ });
26
+
27
+ function frame(timeMs) {
28
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
29
+ ctx.fillStyle = 'black';
30
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
31
+ ctx.fillStyle = 'white';
32
+ ctx.font = 'bold 32px sans-serif';
33
+ ctx.fillText(`Wow, this is bright!`, 20, 50);
34
+
35
+ // Red
36
+ ctx.fillStyle = 'color(display-p3 1 0 0)';
37
+ ctx.fillRect(100, 100, 66, 100);
38
+ ctx.fillStyle = '#FF0000';
39
+ ctx.fillRect(100, 200, 66, 100);
40
+ // Green
41
+ ctx.fillStyle = 'color(display-p3 0 1 0)';
42
+ ctx.fillRect(166, 100, 66, 100);
43
+ ctx.fillStyle = '#00FF00';
44
+ ctx.fillRect(166, 200, 66, 100);
45
+ // Blue
46
+ ctx.fillStyle = 'color(display-p3 0 0 1)';
47
+ ctx.fillRect(232, 100, 66, 100);
48
+ ctx.fillStyle = '#0000FF';
49
+ ctx.fillRect(232, 200, 66, 100);
50
+
51
+ hdr.render();
52
+ requestAnimationFrame(frame);
53
+ }
54
+ requestAnimationFrame(frame);
55
+ </script>
56
+ </body>
57
+
58
+ </html>
@@ -0,0 +1,45 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>bright-canvas</title>
8
+ </head>
9
+
10
+ <body>
11
+ <canvas id="out-canvas" width="400" height="400"></canvas>
12
+ <canvas id="src-canvas" width="400" height="400"></canvas>
13
+ <script type="module">
14
+ import { initHdrCanvas } from '/bright-canvas.js';
15
+ // To use NPM package, move file to builder like Vite, comment out the above line and uncomment the below line
16
+ // import { initHdrCanvas } from 'bright-canvas';
17
+
18
+ const canvas = document.getElementById('src-canvas');
19
+ const ctx = canvas.getContext('2d');
20
+ const outCanvas = document.getElementById('out-canvas');
21
+ const hdr = await initHdrCanvas(canvas, outCanvas, { debug: true });
22
+
23
+ function draw(timeMs) {
24
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
25
+ ctx.fillStyle = 'black';
26
+
27
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
28
+ ctx.fillStyle = 'white';
29
+ ctx.font = 'bold 32px sans-serif';
30
+ ctx.fillText(`Wow, this is bright!`, 20, 50);
31
+
32
+ ctx.fillStyle = `hsl(${timeMs / 5000 * 360} 100% 50%)`;
33
+ ctx.fillRect(100, 100, 200, 200);
34
+ }
35
+
36
+ function frame(timeMs) {
37
+ draw(timeMs);
38
+ hdr.render();
39
+ requestAnimationFrame(frame);
40
+ }
41
+ requestAnimationFrame(frame);
42
+ </script>
43
+ </body>
44
+
45
+ </html>
Binary file
@@ -0,0 +1,243 @@
1
+ // bright-canvas.js
2
+ //
3
+ // Draws the contents of a source canvas onto an output canvas using a
4
+ // WebGPU rgba16float context with extended tone mapping (HDR) when the
5
+ // browser and display support it, or a plain 2D drawImage fallback (SDR).
6
+ //
7
+ // Usage:
8
+ // const hdr = await initHdrCanvas(srcCanvas, outCanvas, options);
9
+ // hdr.mode // 'hdr' | 'sdr'
10
+ // hdr.setBrightness(2.5) // HDR only; no-op in SDR mode
11
+ // hdr.render() // call each frame after drawing to srcCanvas
12
+ // hdr.destroy() // release all GPU resources and references;
13
+ // // render/setBrightness become no-ops
14
+ //
15
+ // Options:
16
+ // debug (default false) - log messages to the console
17
+ // brightness (default 2) - initial brightness factor; can be
18
+ // changed later with setBrightness()
19
+ // sourceFloat16 (default false) - set true when srcCanvas has float16
20
+ // backing (e.g. colorType: 'float16')
21
+ // so extended-range (>1.0) values
22
+ // survive the copy. Not auto-detected:
23
+ // probing would call getContext() on
24
+ // the source canvas, which locks a
25
+ // fresh canvas to that context type.
26
+
27
+ async function initHdrCanvas(
28
+ srcCanvas,
29
+ outCanvas,
30
+ { debug = false, brightness = 2, sourceFloat16 = false } = {},
31
+ ) {
32
+ const log = debug ? (msg) => console.log("[bright-canvas]", msg) : () => {};
33
+
34
+ const W = srcCanvas.width;
35
+ const H = srcCanvas.height;
36
+
37
+ const srcTextureFormat = sourceFloat16 ? "rgba16float" : "rgba8unorm";
38
+
39
+ // Capability check: WebGPU + HDR display
40
+ const hdrMediaQuery = window.matchMedia("(dynamic-range: high)");
41
+ let device = null;
42
+ if (!navigator.gpu) {
43
+ log("⚠️ WebGPU is not available - falling back to a 2D canvas (SDR).");
44
+ } else if (!hdrMediaQuery.matches) {
45
+ log("⚠️ Display isn't HDR-compatible - falling back to a 2D canvas (SDR).");
46
+ } else {
47
+ const adapter = await navigator.gpu.requestAdapter();
48
+ device = (await adapter?.requestDevice()) ?? null;
49
+ if (!device) {
50
+ log(
51
+ "⚠️ Failed to get a WebGPU device - falling back to a 2D canvas (SDR).",
52
+ );
53
+ }
54
+ }
55
+
56
+ // Probe rgba16float + extended tone mapping (Firefox supports WebGPU
57
+ // but not rgba16float canvases). Probing uses a throwaway canvas so
58
+ // out-canvas can still get a '2d' context if we end up falling back.
59
+ let presentationFormat = null;
60
+ if (device) {
61
+ const probeContext = document.createElement("canvas").getContext("webgpu");
62
+ if (probeContext) {
63
+ try {
64
+ probeContext.configure({
65
+ device,
66
+ format: "rgba16float",
67
+ toneMapping: { mode: "extended" },
68
+ });
69
+ presentationFormat = "rgba16float";
70
+ } catch (e) {
71
+ log(`⚠️ Canvas format 'rgba16float' not supported: ${e.message}`);
72
+ }
73
+ probeContext.unconfigure();
74
+ }
75
+ if (!presentationFormat) {
76
+ log(
77
+ "⚠️ No usable WebGPU canvas format - falling back to a 2D canvas (SDR).",
78
+ );
79
+ device = null;
80
+ }
81
+ }
82
+
83
+ // Fallback path: plain 2D canvas, drawImage from the source canvas.
84
+ if (!device) {
85
+ let outCtx = outCanvas.getContext("2d");
86
+ return {
87
+ mode: "sdr",
88
+ setBrightness() {}, // no-op in SDR mode
89
+ render() {
90
+ if (!outCtx) return;
91
+ outCtx.drawImage(srcCanvas, 0, 0, outCanvas.width, outCanvas.height);
92
+ },
93
+ destroy() {
94
+ outCtx = null;
95
+ },
96
+ };
97
+ }
98
+
99
+ // WebGPU path: rgba16float + extended tone mapping (HDR)
100
+ let context = outCanvas.getContext("webgpu");
101
+ context.configure({
102
+ device,
103
+ format: presentationFormat,
104
+ toneMapping: { mode: "extended" },
105
+ });
106
+ log(`ℹ️ WebGPU canvas format: ${presentationFormat}`);
107
+ log(`ℹ️ Source texture format: ${srcTextureFormat}`);
108
+
109
+ if ("getConfiguration" in GPUCanvasContext.prototype) {
110
+ const mode = context.getConfiguration()?.toneMapping?.mode;
111
+ if (mode !== "extended") {
112
+ log(
113
+ "⚠️ Browser doesn't support HDR canvas, fell back to '" + mode + "').",
114
+ );
115
+ } else {
116
+ log("✅ HDR canvas enabled.");
117
+ }
118
+ } else {
119
+ log("ℹ️ getConfiguration() unavailable.");
120
+ }
121
+
122
+ // Bridge: copy the 2D canvas into a texture each frame, then render
123
+ // it fullscreen multiplied by the brightness factor. An 8-bit 2D
124
+ // canvas only holds 0–1 values, so the multiply is what pushes
125
+ // highlights into HDR range.
126
+ let srcTexture = device.createTexture({
127
+ size: [W, H],
128
+ format: srcTextureFormat,
129
+ usage:
130
+ GPUTextureUsage.TEXTURE_BINDING |
131
+ GPUTextureUsage.COPY_DST |
132
+ GPUTextureUsage.RENDER_ATTACHMENT,
133
+ });
134
+
135
+ const shaderModule = device.createShaderModule({
136
+ code: /* wgsl */ `
137
+ @group(0) @binding(0) var samp: sampler;
138
+ @group(0) @binding(1) var tex: texture_2d<f32>;
139
+ @group(0) @binding(2) var<uniform> brightness: f32;
140
+
141
+ struct VSOut {
142
+ @builtin(position) pos: vec4f,
143
+ @location(0) uv: vec2f,
144
+ };
145
+
146
+ @vertex
147
+ fn vs(@builtin(vertex_index) i: u32) -> VSOut {
148
+ // Fullscreen triangle
149
+ let pos = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
150
+ var out: VSOut;
151
+ out.pos = vec4f(pos[i], 0, 1);
152
+ out.uv = pos[i] * vec2f(0.5, -0.5) + 0.5;
153
+ return out;
154
+ }
155
+
156
+ @fragment
157
+ fn fs(in: VSOut) -> @location(0) vec4f {
158
+ let c = textureSample(tex, samp, in.uv);
159
+ return vec4f(c.rgb * brightness, 1.0);
160
+ }
161
+ `,
162
+ });
163
+
164
+ let pipeline = device.createRenderPipeline({
165
+ layout: "auto",
166
+ vertex: { module: shaderModule, entryPoint: "vs" },
167
+ fragment: {
168
+ module: shaderModule,
169
+ entryPoint: "fs",
170
+ targets: [{ format: presentationFormat }],
171
+ },
172
+ primitive: { topology: "triangle-list" },
173
+ });
174
+
175
+ let sampler = device.createSampler({
176
+ magFilter: "linear",
177
+ minFilter: "linear",
178
+ });
179
+ let brightnessBuffer = device.createBuffer({
180
+ size: 4,
181
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
182
+ });
183
+ device.queue.writeBuffer(brightnessBuffer, 0, new Float32Array([brightness]));
184
+
185
+ let bindGroup = device.createBindGroup({
186
+ layout: pipeline.getBindGroupLayout(0),
187
+ entries: [
188
+ { binding: 0, resource: sampler },
189
+ { binding: 1, resource: srcTexture.createView() },
190
+ { binding: 2, resource: { buffer: brightnessBuffer } },
191
+ ],
192
+ });
193
+
194
+ return {
195
+ mode: "hdr",
196
+ setBrightness(value) {
197
+ if (!device) return;
198
+ device.queue.writeBuffer(brightnessBuffer, 0, new Float32Array([value]));
199
+ },
200
+ render() {
201
+ if (!device) return;
202
+ device.queue.copyExternalImageToTexture(
203
+ { source: srcCanvas },
204
+ { texture: srcTexture },
205
+ [W, H],
206
+ );
207
+
208
+ const commandEncoder = device.createCommandEncoder();
209
+ const passEncoder = commandEncoder.beginRenderPass({
210
+ colorAttachments: [
211
+ {
212
+ view: context.getCurrentTexture().createView(),
213
+ clearValue: [0, 0, 0, 1],
214
+ loadOp: "clear",
215
+ storeOp: "store",
216
+ },
217
+ ],
218
+ });
219
+ passEncoder.setPipeline(pipeline);
220
+ passEncoder.setBindGroup(0, bindGroup);
221
+ passEncoder.draw(3);
222
+ passEncoder.end();
223
+ device.queue.submit([commandEncoder.finish()]);
224
+ },
225
+ destroy() {
226
+ if (!device) return;
227
+ srcTexture.destroy();
228
+ brightnessBuffer.destroy();
229
+ context.unconfigure();
230
+ device.destroy();
231
+ srcTexture = null;
232
+ brightnessBuffer = null;
233
+ bindGroup = null;
234
+ pipeline = null;
235
+ sampler = null;
236
+ context = null;
237
+ device = null;
238
+ log("ℹ️ HDR canvas destroyed.");
239
+ },
240
+ };
241
+ }
242
+
243
+ export { initHdrCanvas };
@@ -0,0 +1 @@
1
+ async function e(e,t,{debug:n=!1,brightness:a=2,sourceFloat16:r=!1}={}){const o=n?e=>console.log("[bright-canvas]",e):()=>{},i=e.width,u=e.height,s=r?"rgba16float":"rgba8unorm",l=window.matchMedia("(dynamic-range: high)");let c=null;if(navigator.gpu)if(l.matches){const e=await navigator.gpu.requestAdapter();c=await(e?.requestDevice())??null,c||o("⚠️ Failed to get a WebGPU device - falling back to a 2D canvas (SDR).")}else o("⚠️ Display isn't HDR-compatible - falling back to a 2D canvas (SDR).");else o("⚠️ WebGPU is not available - falling back to a 2D canvas (SDR).");let g=null;if(c){const e=document.createElement("canvas").getContext("webgpu");if(e){try{e.configure({device:c,format:"rgba16float",toneMapping:{mode:"extended"}}),g="rgba16float"}catch(e){o(`⚠️ Canvas format 'rgba16float' not supported: ${e.message}`)}e.unconfigure()}g||(o("⚠️ No usable WebGPU canvas format - falling back to a 2D canvas (SDR)."),c=null)}if(!c){let n=t.getContext("2d");return{mode:"sdr",setBrightness(){},render(){n&&n.drawImage(e,0,0,t.width,t.height)},destroy(){n=null}}}let f=t.getContext("webgpu");if(f.configure({device:c,format:g,toneMapping:{mode:"extended"}}),o(`ℹ️ WebGPU canvas format: ${g}`),o(`ℹ️ Source texture format: ${s}`),"getConfiguration"in GPUCanvasContext.prototype){const e=f.getConfiguration()?.toneMapping?.mode;o("extended"!==e?"⚠️ Browser doesn't support HDR canvas, fell back to '"+e+"').":"✅ HDR canvas enabled.")}else o("ℹ️ getConfiguration() unavailable.");let d=c.createTexture({size:[i,u],format:s,usage:GPUTextureUsage.TEXTURE_BINDING|GPUTextureUsage.COPY_DST|GPUTextureUsage.RENDER_ATTACHMENT});const p=c.createShaderModule({code:"\n @group(0) @binding(0) var samp: sampler;\n @group(0) @binding(1) var tex: texture_2d<f32>;\n @group(0) @binding(2) var<uniform> brightness: f32;\n\n struct VSOut {\n @builtin(position) pos: vec4f,\n @location(0) uv: vec2f,\n };\n\n @vertex\n fn vs(@builtin(vertex_index) i: u32) -> VSOut {\n // Fullscreen triangle\n let pos = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));\n var out: VSOut;\n out.pos = vec4f(pos[i], 0, 1);\n out.uv = pos[i] * vec2f(0.5, -0.5) + 0.5;\n return out;\n }\n\n @fragment\n fn fs(in: VSOut) -> @location(0) vec4f {\n let c = textureSample(tex, samp, in.uv);\n return vec4f(c.rgb * brightness, 1.0);\n }\n "});let v=c.createRenderPipeline({layout:"auto",vertex:{module:p,entryPoint:"vs"},fragment:{module:p,entryPoint:"fs",targets:[{format:g}]},primitive:{topology:"triangle-list"}}),m=c.createSampler({magFilter:"linear",minFilter:"linear"}),b=c.createBuffer({size:4,usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST});c.queue.writeBuffer(b,0,new Float32Array([a]));let x=c.createBindGroup({layout:v.getBindGroupLayout(0),entries:[{binding:0,resource:m},{binding:1,resource:d.createView()},{binding:2,resource:{buffer:b}}]});return{mode:"hdr",setBrightness(e){c&&c.queue.writeBuffer(b,0,new Float32Array([e]))},render(){if(!c)return;c.queue.copyExternalImageToTexture({source:e},{texture:d},[i,u]);const t=c.createCommandEncoder(),n=t.beginRenderPass({colorAttachments:[{view:f.getCurrentTexture().createView(),clearValue:[0,0,0,1],loadOp:"clear",storeOp:"store"}]});n.setPipeline(v),n.setBindGroup(0,x),n.draw(3),n.end(),c.queue.submit([t.finish()])},destroy(){c&&(d.destroy(),b.destroy(),f.unconfigure(),c.destroy(),d=null,b=null,x=null,v=null,m=null,f=null,c=null,o("ℹ️ HDR canvas destroyed."))}}}export{e as initHdrCanvas};
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "bright-canvas",
3
+ "version": "0.1.0",
4
+ "description": "Brighten any canvas with proper HDR support. No flags required.",
5
+ "license": "MIT",
6
+ "author": "Inclushe",
7
+ "type": "module",
8
+ "main": "dist/bright-canvas.min.js",
9
+ "scripts": {
10
+ "build": "rollup -c"
11
+ },
12
+ "keywords": [
13
+ "canvas",
14
+ "webgpu",
15
+ "hdr"
16
+ ],
17
+ "devDependencies": {
18
+ "@rollup/plugin-json": "^6.1.0",
19
+ "@rollup/plugin-terser": "^1.0.0",
20
+ "rollup": "^4.62.2"
21
+ }
22
+ }
@@ -0,0 +1,20 @@
1
+ // rollup.config.mjs
2
+ import json from "@rollup/plugin-json";
3
+ import terser from "@rollup/plugin-terser";
4
+
5
+ export default {
6
+ input: "bright-canvas.js",
7
+ output: [
8
+ {
9
+ file: "dist/bright-canvas.js",
10
+ format: "es",
11
+ },
12
+ {
13
+ file: "dist/bright-canvas.min.js",
14
+ format: "es",
15
+ name: "version",
16
+ plugins: [terser()],
17
+ },
18
+ ],
19
+ plugins: [json()],
20
+ };