react-native-webrtc-kaleidoscope 2.7.5 → 2.7.6

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.
@@ -1,287 +0,0 @@
1
- #!/usr/bin/env bun
2
- // Thumbnail maker (issue #65): generate a standardized 320x180 WebP thumbnail
3
- // for EVERY preset in a declared preset book, stomping existing files.
4
- //
5
- // bunx kaleidoscope-thumbnails --book ./kaleidoscope.preset-book.ts --out ./assets/thumbnails
6
- // bun run thumbs # this repo: demo book, repo conventions (see --repo)
7
- //
8
- // What it does: loads the book as data (see book-loader.ts), projects each
9
- // preset's NON-SUBJECT layer stack (no mask, no person) into a render spec,
10
- // and drives a headless-Chromium page (render-page.ts) that composites the
11
- // stack — generative shaders at their exact preset uniforms over control
12
- // defaults, image layers cover-fit, blur layers blurring the bundled
13
- // "virtual scene" office fixture standing in for the camera — then encodes
14
- // the smallest WebP that survives an RMSE gate against the raw render.
15
- //
16
- // Output: `<out>/<preset-id>.thumb.webp` per preset, plus printed `thumbnail:`
17
- // wiring suggestions. The tool never rewrites the book. With --repo (this
18
- // repository's convention) two preset classes are redirected:
19
- // - a preset whose id matches a packaged composite stomps
20
- // `catalog/composites/<id>/<id>.thumb.webp` (already wired by id);
21
- // - a single-image preset whose plate lives under `catalog/images/`
22
- // stomps the sibling `<leaf>.thumb.webp` (the existing image-thumb
23
- // convention).
24
- //
25
- // Requirements: Bun, and `playwright` resolvable from the working directory
26
- // (it is a devDependency here; consumers install it themselves — this is an
27
- // opt-in command, not runtime code). The default browser flags reach a real
28
- // GPU through WSLg/ANGLE; pass --no-gl-flags on platforms where they fight
29
- // the default (macOS).
30
-
31
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
32
- import { tmpdir } from 'node:os';
33
- import path from 'node:path';
34
- import { type LoadedPreset, loadPresetBook } from './book-loader';
35
- import { type PageLayerSpec, type PagePresetSpec, type PageResult, generatePage } from './render-page';
36
-
37
- const TOOL_DIR = path.dirname(new URL(import.meta.url).pathname);
38
- const PKG_ROOT = path.resolve(TOOL_DIR, '..', '..');
39
-
40
- type Args = {
41
- book: string;
42
- out: string;
43
- fixture: string;
44
- repo: boolean;
45
- glFlags: boolean;
46
- };
47
-
48
- function parseArgs(argv: string[]): Args {
49
- const a: Args = {
50
- book: 'kaleidoscope.preset-book.ts',
51
- out: 'assets/thumbnails',
52
- fixture: path.join(TOOL_DIR, 'office-fixture.webp'),
53
- repo: false,
54
- glFlags: true,
55
- };
56
- for (let i = 0; i < argv.length; i++) {
57
- const k = argv[i];
58
- if (k === '--book') a.book = argv[++i] ?? a.book;
59
- else if (k === '--out') a.out = argv[++i] ?? a.out;
60
- else if (k === '--fixture') a.fixture = argv[++i] ?? a.fixture;
61
- else if (k === '--repo') a.repo = true;
62
- else if (k === '--no-gl-flags') a.glFlags = false;
63
- else if (k === '--help' || k === '-h') {
64
- console.log(
65
- 'usage: kaleidoscope-thumbnails --book <preset-book.ts> --out <dir> [--fixture <img>] [--repo] [--no-gl-flags]',
66
- );
67
- process.exit(0);
68
- }
69
- }
70
- return a;
71
- }
72
-
73
- function toDataUrl(filePath: string): string {
74
- const ext = path.extname(filePath).slice(1).toLowerCase();
75
- const mime = ext === 'jpg' ? 'jpeg' : ext;
76
- return `data:image/${mime};base64,${readFileSync(filePath).toString('base64')}`;
77
- }
78
-
79
- /** Find `catalog/images/<category>/<id>.webp` for a native image id. */
80
- function findCatalogImage(id: string): string | null {
81
- const root = path.join(PKG_ROOT, 'catalog', 'images');
82
- if (!existsSync(root)) return null;
83
- for (const cat of readdirSync(root, { withFileTypes: true })) {
84
- if (!cat.isDirectory()) continue;
85
- const candidate = path.join(root, cat.name, `${id}.webp`);
86
- if (existsSync(candidate)) return candidate;
87
- }
88
- return null;
89
- }
90
-
91
- /** Resolve an image layer's source (path, native id, or book-relative) to a file. */
92
- function resolveImagePath(source: string, bookDir: string): string | null {
93
- if (existsSync(source)) return source;
94
- const catalogHit = findCatalogImage(source);
95
- if (catalogHit) return catalogHit;
96
- const rel = path.resolve(bookDir, source);
97
- if (existsSync(rel)) return rel;
98
- return null;
99
- }
100
-
101
- /** Shader-name -> default-uniform map, read from the built control descriptors. */
102
- async function loadShaderDefaults(): Promise<Record<string, Record<string, number | number[]>>> {
103
- const mod = (await import(path.join(PKG_ROOT, 'dist', 'catalog', 'shaders', 'index.js'))) as Record<
104
- string,
105
- unknown
106
- >;
107
- const defaults: Record<string, Record<string, number | number[]>> = {};
108
- for (const [exportName, value] of Object.entries(mod)) {
109
- if (!exportName.endsWith('_CONTROLS') || !Array.isArray(value)) continue;
110
- const shader = exportName.slice(0, -'_CONTROLS'.length).toLowerCase().replace(/_/g, '-');
111
- const map: Record<string, number | number[]> = {};
112
- for (const c of value as Array<{ name: string; default: number | readonly number[] }>) {
113
- map[c.name] = Array.isArray(c.default) ? [...c.default] : (c.default as number);
114
- }
115
- defaults[shader] = map;
116
- }
117
- return defaults;
118
- }
119
-
120
- type Job = {
121
- readonly id: string;
122
- readonly spec: PagePresetSpec;
123
- readonly dest: string;
124
- readonly extraDests: readonly string[]; // additional copies (catalog stomp + book-local)
125
- readonly wire: boolean; // whether to print a thumbnail: wiring suggestion
126
- };
127
-
128
- function buildJobs(
129
- book: Record<string, LoadedPreset>,
130
- args: Args,
131
- bookDir: string,
132
- defaults: Record<string, Record<string, number | number[]>>,
133
- shaderNames: ReadonlySet<string>,
134
- ): Job[] {
135
- const jobs: Job[] = [];
136
- for (const [id, preset] of Object.entries(book)) {
137
- const layers: PageLayerSpec[] = [];
138
- const imagePaths: string[] = [];
139
- let skip: string | null = null;
140
-
141
- for (const layer of preset.layers) {
142
- if (layer.target === 'subject' || layer.shader === 'direct') continue;
143
- const blend = layer.blend;
144
- if (layer.shader === 'image') {
145
- const resolved = layer.source ? resolveImagePath(layer.source, bookDir) : null;
146
- if (!resolved) {
147
- skip = `image layer '${layer.id ?? '?'}' source '${layer.source}' not found`;
148
- break;
149
- }
150
- imagePaths.push(resolved);
151
- layers.push({ kind: 'image', dataUrl: toDataUrl(resolved), blend });
152
- } else if (layer.shader === 'blur') {
153
- const sigma = Number(layer.uniforms?.sigma ?? defaults.blur?.sigma ?? 3);
154
- layers.push({ kind: 'blur', sigma, blend });
155
- } else if (shaderNames.has(layer.shader)) {
156
- const uniforms = { ...(defaults[layer.shader] ?? {}), ...(layer.uniforms ?? {}) };
157
- const plain: Record<string, number | number[]> = {};
158
- for (const [k, v] of Object.entries(uniforms)) plain[k] = Array.isArray(v) ? [...v] : (v as number);
159
- layers.push({ kind: 'shader', name: layer.shader, uniforms: plain, blend });
160
- } else {
161
- skip = `unknown layer shader '${layer.shader}'`;
162
- break;
163
- }
164
- }
165
- if (skip) {
166
- console.warn(` skip ${id}: ${skip}`);
167
- continue;
168
- }
169
- if (layers.length === 0) {
170
- console.warn(` skip ${id}: no renderable background layers`);
171
- continue;
172
- }
173
-
174
- // Destination: repo conventions split composites and catalog images.
175
- let dest = path.resolve(args.out, `${id}.thumb.webp`);
176
- const extraDests: string[] = [];
177
- let wire = true;
178
- if (args.repo) {
179
- const compositeDir = path.join(PKG_ROOT, 'catalog', 'composites', id);
180
- const onlyImage = layers.length === 1 && layers[0]?.kind === 'image' && imagePaths[0];
181
- if (existsSync(compositeDir)) {
182
- dest = path.join(compositeDir, `${id}.thumb.webp`);
183
- wire = false; // already wired by id (native) and by module (web)
184
- } else if (onlyImage && imagePaths[0]?.startsWith(path.join(PKG_ROOT, 'catalog', 'images'))) {
185
- // Stomp the catalog's image-thumb convention AND keep a book-local
186
- // copy: the package's exports map does not expose catalog thumbs, so
187
- // the book wires the local one.
188
- const img = imagePaths[0];
189
- extraDests.push(path.join(path.dirname(img), `${path.basename(img, '.webp')}.thumb.webp`));
190
- }
191
- }
192
- jobs.push({ id, spec: { id, layers }, dest, extraDests, wire });
193
- }
194
- return jobs;
195
- }
196
-
197
- async function main(): Promise<void> {
198
- const args = parseArgs(process.argv.slice(2));
199
- if (!existsSync(args.book)) {
200
- console.error(`book not found: ${args.book}`);
201
- process.exit(2);
202
- }
203
- if (!existsSync(args.fixture)) {
204
- console.error(`camera fixture not found: ${args.fixture}`);
205
- process.exit(2);
206
- }
207
-
208
- let chromium: typeof import('playwright').chromium;
209
- try {
210
- ({ chromium } = await import('playwright'));
211
- } catch {
212
- console.error(
213
- 'playwright is required: install it as a devDependency (`bun add -d playwright && bunx playwright install chromium`).',
214
- );
215
- process.exit(2);
216
- }
217
-
218
- const book = await loadPresetBook(args.book);
219
- const bookDir = path.dirname(path.resolve(args.book));
220
- const defaults = await loadShaderDefaults();
221
-
222
- const generated = (await import(
223
- path.join(PKG_ROOT, 'dist', 'web-driver', 'shaders.generated.js')
224
- )) as {
225
- SHADER_SOURCES: Readonly<Record<string, string>>;
226
- COMPOSITE_BLUR_FRAG_SRC: string;
227
- };
228
-
229
- const jobs = buildJobs(book, args, bookDir, defaults, new Set(Object.keys(generated.SHADER_SOURCES)));
230
- console.log(`${jobs.length} preset(s) to render from ${path.relative(process.cwd(), args.book)}`);
231
-
232
- const html = generatePage({
233
- shaderSources: generated.SHADER_SOURCES,
234
- blurFragSrc: generated.COMPOSITE_BLUR_FRAG_SRC,
235
- fixtureDataUrl: toDataUrl(args.fixture),
236
- });
237
- const pagePath = path.join(tmpdir(), `kaleidoscope-thumbs-${process.pid}.html`);
238
- writeFileSync(pagePath, html);
239
-
240
- const browser = await chromium.launch({
241
- args: args.glFlags ? ['--use-gl=angle', '--use-angle=gl'] : [],
242
- });
243
- const page = await browser.newPage({ viewport: { width: 800, height: 500 } });
244
- page.on('pageerror', (e) => console.error(` page error: ${e.message}`));
245
- await page.goto(`file://${pagePath}`);
246
-
247
- const wires: string[] = [];
248
- let failures = 0;
249
- for (const job of jobs) {
250
- try {
251
- const result = (await page.evaluate(
252
- // biome-ignore lint/suspicious/noExplicitAny: page-scope function injected by render-page
253
- (spec) => (window as any).renderPreset(spec),
254
- job.spec,
255
- )) as PageResult;
256
- const b64 = result.dataUrl.slice('data:image/webp;base64,'.length);
257
- for (const dest of [job.dest, ...job.extraDests]) {
258
- mkdirSync(path.dirname(dest), { recursive: true });
259
- writeFileSync(dest, Buffer.from(b64, 'base64'));
260
- }
261
- const kb = (Buffer.byteLength(b64, 'base64') / 1024).toFixed(1);
262
- console.log(
263
- ` ok ${job.id} -> ${path.relative(process.cwd(), job.dest)} (${kb} KB, q ${result.q}, rmse ${result.rmse.toFixed(2)})`,
264
- );
265
- if (job.wire) wires.push(job.id);
266
- } catch (e) {
267
- failures++;
268
- console.error(` FAIL ${job.id}: ${e instanceof Error ? e.message : e}`);
269
- }
270
- }
271
- await browser.close();
272
-
273
- if (wires.length > 0) {
274
- console.log('\nWire these into the book (the tool never edits it):');
275
- for (const id of wires) {
276
- console.log(
277
- ` '${id}': thumbnail: Asset.fromModule(require('./${path.relative(bookDir, path.resolve(args.out, `${id}.thumb.webp`)).replace(/\\\\/g, '/')}')).uri,`,
278
- );
279
- }
280
- }
281
- if (failures > 0) {
282
- console.error(`\n${failures} preset(s) failed`);
283
- process.exit(1);
284
- }
285
- }
286
-
287
- main();
@@ -1,259 +0,0 @@
1
- // The thumbnail maker's render page (issue #65): a self-contained HTML
2
- // document that composites one preset's non-subject layer stack and encodes a
3
- // 320x180 WebP, all in-browser (the browser's own encoder; no native image
4
- // deps). The CLI drives it per preset via `window.renderPreset(spec)`.
5
- //
6
- // Rendering model (mirrors the web compositor's semantics at thumbnail scale):
7
- // - shader layers draw on a WebGL2 canvas (the generated single-source
8
- // frags, exact preset uniforms over control defaults, fixed mid-animation
9
- // uTime so output is deterministic and past initialization);
10
- // - image layers cover-fit onto the 2D compositing canvas;
11
- // - blur layers run the real composite-blur two-pass over the bundled
12
- // "virtual scene" office fixture (the camera stand-in);
13
- // - `additive` blend maps to canvas 'lighter', everything else paints over.
14
- //
15
- // Render happens at 2x (640x360) and downscales to 320x180 for antialiasing.
16
- // Encoding sweeps WebP quality from low to high and keeps the first level
17
- // whose decode round-trips within an RMSE gate against the uncompressed
18
- // canvas: smallest file with no visible artifacting.
19
-
20
- export type PageLayerSpec =
21
- | {
22
- readonly kind: 'shader';
23
- readonly name: string;
24
- readonly uniforms: Record<string, number | readonly number[]>;
25
- readonly blend?: string;
26
- }
27
- | { readonly kind: 'image'; readonly dataUrl: string; readonly blend?: string }
28
- | { readonly kind: 'blur'; readonly sigma: number; readonly blend?: string };
29
-
30
- export type PagePresetSpec = {
31
- readonly id: string;
32
- readonly layers: readonly PageLayerSpec[];
33
- };
34
-
35
- export type PageResult = {
36
- readonly dataUrl: string;
37
- readonly q: number;
38
- readonly bytes: number;
39
- readonly rmse: number;
40
- };
41
-
42
- const FULLSCREEN_VERT = `#version 300 es
43
- out highp vec2 vUv;
44
- void main() {
45
- vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
46
- vUv = p;
47
- gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
48
- }`;
49
-
50
- export function generatePage(opts: {
51
- readonly shaderSources: Readonly<Record<string, string>>;
52
- readonly blurFragSrc: string;
53
- readonly fixtureDataUrl: string;
54
- }): string {
55
- const data = JSON.stringify({
56
- vert: FULLSCREEN_VERT,
57
- sources: opts.shaderSources,
58
- blurSrc: opts.blurFragSrc,
59
- fixture: opts.fixtureDataUrl,
60
- });
61
- return `<!doctype html>
62
- <html><head><meta charset="utf-8"><title>kaleidoscope thumbnails</title></head>
63
- <body>
64
- <script>
65
- const DATA = ${data};
66
- const W = 640, H = 360, TW = 320, TH = 180;
67
- // Fixed mid-animation clock: deterministic, and well past t=0 initialization.
68
- const T = 7.0;
69
- // RMSE gate (0..255 scale) for "no visible artifacting" on a 320x180 thumb.
70
- const RMSE_MAX = 2.6;
71
- const QUALITIES = [0.5, 0.6, 0.68, 0.76, 0.84, 0.92];
72
-
73
- const glc = document.createElement('canvas');
74
- glc.width = W; glc.height = H;
75
- const gl = glc.getContext('webgl2', { antialias: false, preserveDrawingBuffer: true });
76
-
77
- const progCache = {};
78
- function compile(type, src) {
79
- const s = gl.createShader(type);
80
- gl.shaderSource(s, src); gl.compileShader(s);
81
- if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
82
- throw new Error('shader compile: ' + (gl.getShaderInfoLog(s) || '?'));
83
- }
84
- return s;
85
- }
86
- function program(key, fragSrc) {
87
- if (progCache[key]) return progCache[key];
88
- const p = gl.createProgram();
89
- gl.attachShader(p, compile(gl.VERTEX_SHADER, DATA.vert));
90
- gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fragSrc));
91
- gl.linkProgram(p);
92
- if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
93
- throw new Error('link ' + key + ': ' + (gl.getProgramInfoLog(p) || '?'));
94
- }
95
- progCache[key] = p;
96
- return p;
97
- }
98
-
99
- function setUniform(p, name, v) {
100
- const loc = gl.getUniformLocation(p, name) || gl.getUniformLocation(p, name + '[0]');
101
- if (!loc) return;
102
- if (typeof v === 'number') { gl.uniform1f(loc, v); return; }
103
- if (!Array.isArray(v)) return;
104
- if (v.length === 2) gl.uniform2f(loc, v[0], v[1]);
105
- else if (v.length === 3) gl.uniform3f(loc, v[0], v[1], v[2]);
106
- else if (v.length === 4) gl.uniform4f(loc, v[0], v[1], v[2], v[3]);
107
- else gl.uniform2fv(loc, new Float32Array(v)); // a flat vec2[] (polygon)
108
- }
109
- function setUniforms(p, uniforms, w, h) {
110
- gl.useProgram(p);
111
- setUniform(p, 'uTime', T);
112
- setUniform(p, 'uResolution', [w, h]);
113
- for (const k in uniforms) setUniform(p, k, uniforms[k]);
114
- }
115
-
116
- function renderShader(name, uniforms) {
117
- const src = DATA.sources[name];
118
- if (!src) throw new Error('no shader source registered for "' + name + '"');
119
- const p = program(name, src);
120
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
121
- gl.viewport(0, 0, W, H);
122
- setUniforms(p, uniforms, W, H);
123
- gl.drawArrays(gl.TRIANGLES, 0, 3);
124
- }
125
-
126
- function imgToTexture(img) {
127
- const tex = gl.createTexture();
128
- gl.bindTexture(gl.TEXTURE_2D, tex);
129
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
130
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
131
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
132
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
133
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
134
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
135
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
136
- return tex;
137
- }
138
- function makeFbo(w, h) {
139
- const tex = gl.createTexture();
140
- gl.bindTexture(gl.TEXTURE_2D, tex);
141
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
142
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
143
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
144
- const fbo = gl.createFramebuffer();
145
- gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
146
- gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
147
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
148
- return { fbo, tex };
149
- }
150
-
151
- let fixtureImgP = null;
152
- function fixtureImg() {
153
- if (!fixtureImgP) fixtureImgP = loadImg(DATA.fixture);
154
- return fixtureImgP;
155
- }
156
- function loadImg(url) {
157
- return new Promise((res, rej) => {
158
- const i = new Image();
159
- i.onload = () => res(i);
160
- i.onerror = () => rej(new Error('image failed to load'));
161
- i.src = url;
162
- });
163
- }
164
-
165
- // The real composite-blur, both separable passes, over the camera stand-in.
166
- async function renderBlur(sigma) {
167
- const img = await fixtureImg();
168
- const srcTex = imgToTexture(img);
169
- const pass = makeFbo(W, H);
170
- const p = program('composite-blur', DATA.blurSrc);
171
- gl.activeTexture(gl.TEXTURE0);
172
-
173
- gl.bindFramebuffer(gl.FRAMEBUFFER, pass.fbo);
174
- gl.viewport(0, 0, W, H);
175
- gl.bindTexture(gl.TEXTURE_2D, srcTex);
176
- gl.useProgram(p);
177
- setUniform(p, 'uTex', 0);
178
- const texLoc = gl.getUniformLocation(p, 'uTex');
179
- if (texLoc) gl.uniform1i(texLoc, 0);
180
- setUniform(p, 'uSigma', sigma);
181
- setUniform(p, 'uDir', [1 / W, 0]);
182
- gl.drawArrays(gl.TRIANGLES, 0, 3);
183
-
184
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
185
- gl.viewport(0, 0, W, H);
186
- gl.bindTexture(gl.TEXTURE_2D, pass.tex);
187
- if (texLoc) gl.uniform1i(texLoc, 0);
188
- setUniform(p, 'uSigma', sigma);
189
- setUniform(p, 'uDir', [0, 1 / H]);
190
- gl.drawArrays(gl.TRIANGLES, 0, 3);
191
- }
192
-
193
- function coverDraw(ctx, img, w, h) {
194
- const s = Math.max(w / img.naturalWidth, h / img.naturalHeight);
195
- const dw = img.naturalWidth * s, dh = img.naturalHeight * s;
196
- ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
197
- }
198
-
199
- async function rmseAgainst(ref, dataUrl) {
200
- const img = await loadImg(dataUrl);
201
- const c = document.createElement('canvas');
202
- c.width = TW; c.height = TH;
203
- const x = c.getContext('2d');
204
- x.drawImage(img, 0, 0);
205
- const got = x.getImageData(0, 0, TW, TH).data;
206
- let sum = 0, n = 0;
207
- for (let i = 0; i < got.length; i += 4) {
208
- for (let ch = 0; ch < 3; ch++) {
209
- const d = got[i + ch] - ref.data[i + ch];
210
- sum += d * d; n++;
211
- }
212
- }
213
- return Math.sqrt(sum / n);
214
- }
215
-
216
- window.renderPreset = async (spec) => {
217
- const comp = document.createElement('canvas');
218
- comp.width = W; comp.height = H;
219
- const ctx = comp.getContext('2d');
220
- ctx.fillStyle = '#000';
221
- ctx.fillRect(0, 0, W, H);
222
-
223
- for (const layer of spec.layers) {
224
- ctx.globalCompositeOperation = layer.blend === 'additive' ? 'lighter' : 'source-over';
225
- if (layer.kind === 'image') {
226
- coverDraw(ctx, await loadImg(layer.dataUrl), W, H);
227
- } else if (layer.kind === 'blur') {
228
- await renderBlur(layer.sigma);
229
- ctx.drawImage(glc, 0, 0, W, H);
230
- } else {
231
- renderShader(layer.name, layer.uniforms);
232
- ctx.drawImage(glc, 0, 0, W, H);
233
- }
234
- }
235
- ctx.globalCompositeOperation = 'source-over';
236
-
237
- const tc = document.createElement('canvas');
238
- tc.width = TW; tc.height = TH;
239
- const tctx = tc.getContext('2d');
240
- tctx.drawImage(comp, 0, 0, TW, TH);
241
- const ref = tctx.getImageData(0, 0, TW, TH);
242
-
243
- for (const q of QUALITIES) {
244
- const url = tc.toDataURL('image/webp', q);
245
- const rmse = await rmseAgainst(ref, url);
246
- if (rmse <= RMSE_MAX) {
247
- return { dataUrl: url, q, bytes: Math.round((url.length - 'data:image/webp;base64,'.length) * 0.75), rmse };
248
- }
249
- }
250
- // Noisy content (dot lattices, starfields, confetti) never satisfies an
251
- // RMSE gate; the per-pixel error is invisible dither. Cap at the sweep's
252
- // top quality instead of escalating further.
253
- const url = tc.toDataURL('image/webp', 0.92);
254
- const rmse = await rmseAgainst(ref, url);
255
- return { dataUrl: url, q: 0.92, bytes: Math.round((url.length - 'data:image/webp;base64,'.length) * 0.75), rmse };
256
- };
257
- </script>
258
- </body></html>`;
259
- }