react-native-webrtc-kaleidoscope 2.4.0 → 2.5.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/catalog/composites/clouds/clouds.thumb.webp +0 -0
- package/catalog/composites/corporate-blobs/corporate-blobs.thumb.webp +0 -0
- package/catalog/composites/fairy-cave/fairy-cave.thumb.webp +0 -0
- package/catalog/composites/fairy-grotto/fairy-grotto.thumb.webp +0 -0
- package/catalog/composites/fairy-hollow/fairy-hollow.thumb.webp +0 -0
- package/catalog/composites/nebula/nebula.thumb.webp +0 -0
- package/catalog/composites/observation-deck/observation-deck.thumb.webp +0 -0
- package/catalog/composites/simianlights/simianlights.thumb.webp +0 -0
- package/catalog/composites/underwater/underwater.thumb.webp +0 -0
- package/catalog/composites/wizard-tower/wizard-tower.thumb.webp +0 -0
- package/catalog/composites/wizard-tower-night/wizard-tower-night.thumb.webp +0 -0
- package/catalog/images/debug/debug-resolutions.thumb.webp +0 -0
- package/catalog/images/home/home-dark.thumb.webp +0 -0
- package/catalog/images/home/home-light.thumb.webp +0 -0
- package/catalog/images/nature/landscape-dark.thumb.webp +0 -0
- package/catalog/images/nature/landscape-light.thumb.webp +0 -0
- package/catalog/images/office/office-dark.thumb.webp +0 -0
- package/catalog/images/office/office-light.thumb.webp +0 -0
- package/catalog/images/sci-fi/sci-fi-light.thumb.webp +0 -0
- package/catalog/images/simiancraft/simiancraft-dark.thumb.webp +0 -0
- package/catalog/images/simiancraft/simiancraft-light.thumb.webp +0 -0
- package/catalog/images/underwater/oceanscape-dark.thumb.webp +0 -0
- package/dist/catalog/composites/clouds/clouds.thumb.webp +0 -0
- package/dist/catalog/composites/corporate-blobs/corporate-blobs.thumb.webp +0 -0
- package/dist/catalog/composites/fairy-cave/fairy-cave.thumb.webp +0 -0
- package/dist/catalog/composites/fairy-grotto/fairy-grotto.thumb.webp +0 -0
- package/dist/catalog/composites/fairy-hollow/fairy-hollow.thumb.webp +0 -0
- package/dist/catalog/composites/nebula/nebula.thumb.webp +0 -0
- package/dist/catalog/composites/observation-deck/observation-deck.thumb.webp +0 -0
- package/dist/catalog/composites/simianlights/simianlights.thumb.webp +0 -0
- package/dist/catalog/composites/underwater/underwater.thumb.webp +0 -0
- package/dist/catalog/composites/wizard-tower/wizard-tower.thumb.webp +0 -0
- package/dist/catalog/composites/wizard-tower-night/wizard-tower-night.thumb.webp +0 -0
- package/dist/catalog/images/debug/debug-resolutions.thumb.webp +0 -0
- package/dist/catalog/images/home/home-dark.thumb.webp +0 -0
- package/dist/catalog/images/home/home-light.thumb.webp +0 -0
- package/dist/catalog/images/nature/landscape-dark.thumb.webp +0 -0
- package/dist/catalog/images/nature/landscape-light.thumb.webp +0 -0
- package/dist/catalog/images/office/office-dark.thumb.webp +0 -0
- package/dist/catalog/images/office/office-light.thumb.webp +0 -0
- package/dist/catalog/images/sci-fi/sci-fi-light.thumb.webp +0 -0
- package/dist/catalog/images/simiancraft/simiancraft-dark.thumb.webp +0 -0
- package/dist/catalog/images/simiancraft/simiancraft-light.thumb.webp +0 -0
- package/dist/catalog/images/underwater/oceanscape-dark.thumb.webp +0 -0
- package/package.json +7 -2
- package/tools/thumbnails/book-loader.ts +104 -0
- package/tools/thumbnails/make-thumbnails.ts +287 -0
- package/tools/thumbnails/office-fixture.webp +0 -0
- package/tools/thumbnails/render-page.ts +259 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-webrtc-kaleidoscope",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "Live video effects (blur, background replacement, generative backgrounds, flip/rotate) for react-native-webrtc, packaged as a managed-Expo-friendly Expo Module. Working on web, Android, and iOS. Active development.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -524,10 +524,14 @@
|
|
|
524
524
|
"./expo-module.config.json": "./expo-module.config.json",
|
|
525
525
|
"./package.json": "./package.json"
|
|
526
526
|
},
|
|
527
|
+
"bin": {
|
|
528
|
+
"kaleidoscope-thumbnails": "./tools/thumbnails/make-thumbnails.ts"
|
|
529
|
+
},
|
|
527
530
|
"files": [
|
|
528
531
|
"src",
|
|
529
532
|
"catalog",
|
|
530
533
|
"dist",
|
|
534
|
+
"tools",
|
|
531
535
|
"android/src",
|
|
532
536
|
"android/build.gradle",
|
|
533
537
|
"ios",
|
|
@@ -568,6 +572,7 @@
|
|
|
568
572
|
"check:shaders": "bun run build:shaders && git diff --exit-code -- android/src/main/java/com/simiancraft/kaleidoscope/gpu/ShadersGenerated.kt web-driver/shaders.generated.ts",
|
|
569
573
|
"bench:shader": "bun run scripts/shader-cost.ts",
|
|
570
574
|
"shader:view": "bun run scripts/shader-view.ts",
|
|
575
|
+
"thumbs": "bun run build && bun tools/thumbnails/make-thumbnails.ts --book demo/kaleidoscope.preset-book.ts --out demo/assets/thumbnails --repo",
|
|
571
576
|
"demo": "bun run build && cd demo && bun run start",
|
|
572
577
|
"demo:wsl": "bun run build && cd demo && bun run start:wsl",
|
|
573
578
|
"demo:ios": "bun run build && cd demo && bun run ios",
|
|
@@ -634,7 +639,7 @@
|
|
|
634
639
|
"lefthook": "^2.1.9",
|
|
635
640
|
"livekit-client": "^2.0.0",
|
|
636
641
|
"nativewind": "4.2.x",
|
|
637
|
-
"playwright": "
|
|
642
|
+
"playwright": "1.60.0",
|
|
638
643
|
"publint": "^0.3.18",
|
|
639
644
|
"react": "18.2.0",
|
|
640
645
|
"react-native": "0.74.5",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Loads a preset book as DATA by executing it under Bun with three shims
|
|
2
|
+
// (issue #65). The book is real TypeScript that imports React control
|
|
3
|
+
// components, expo-asset, and bundled images; none of those matter to a
|
|
4
|
+
// thumbnail, so each is replaced with the smallest stand-in that keeps the
|
|
5
|
+
// module graph executable:
|
|
6
|
+
//
|
|
7
|
+
// - `expo-asset`: `Asset.fromModule(m).uri` returns the module value itself,
|
|
8
|
+
// which (via the asset shim below) is the file's absolute path.
|
|
9
|
+
// - control components (`*.form.*`, `*.controls.*`): a Proxy that satisfies
|
|
10
|
+
// any named import without pulling React in; presets reference these as
|
|
11
|
+
// values but thumbnails never render them.
|
|
12
|
+
// - bundled images (`.webp` / `.png` / `.jpg`): the file's absolute path as
|
|
13
|
+
// the default export, so an image layer's `source` resolves to something
|
|
14
|
+
// the CLI can read and embed.
|
|
15
|
+
//
|
|
16
|
+
// Executing (vs static parsing, which the prebuild plugin does for asset
|
|
17
|
+
// collection) is what yields exact per-preset layer stacks and uniform values
|
|
18
|
+
// with no fragile object-literal parsing. This is Bun-only by design; the
|
|
19
|
+
// thumbnail maker is an opt-in dev command, not runtime code.
|
|
20
|
+
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { plugin } from 'bun';
|
|
23
|
+
|
|
24
|
+
/** One layer as authored in a book (the subset thumbnails care about). */
|
|
25
|
+
type LoadedLayer = {
|
|
26
|
+
readonly id?: string;
|
|
27
|
+
readonly shader: string;
|
|
28
|
+
readonly target?: string;
|
|
29
|
+
readonly blend?: string;
|
|
30
|
+
readonly source?: string;
|
|
31
|
+
readonly uniforms?: Record<string, number | readonly number[]>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** One preset as authored in a book. */
|
|
35
|
+
export type LoadedPreset = {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly taxonomy: readonly string[];
|
|
38
|
+
readonly thumbnail?: string | number;
|
|
39
|
+
readonly layers: readonly LoadedLayer[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
plugin({
|
|
43
|
+
name: 'kaleidoscope-thumbnails-book-shims',
|
|
44
|
+
setup(build) {
|
|
45
|
+
// Path-based onLoad (not onResolve, which Bun's runtime plugins do not
|
|
46
|
+
// reliably fire): any file inside the expo-asset package becomes the
|
|
47
|
+
// shim, so its entry never executes and never drags React Native's
|
|
48
|
+
// Flow-typed asset registry in.
|
|
49
|
+
build.onLoad({ filter: /node_modules\/expo-asset\/.*\.[cm]?js$/ }, () => ({
|
|
50
|
+
contents: [
|
|
51
|
+
'function unwrap(m) {',
|
|
52
|
+
' if (typeof m === "string") return m;',
|
|
53
|
+
' try { if (m && typeof m.default === "string") return m.default; } catch {}',
|
|
54
|
+
' try { if (m && typeof m.uri === "string") return m.uri; } catch {}',
|
|
55
|
+
' try { return String(m); } catch { return ""; }',
|
|
56
|
+
'}',
|
|
57
|
+
'export const Asset = { fromModule: (m) => ({ uri: unwrap(m) }) };',
|
|
58
|
+
].join('\n'),
|
|
59
|
+
loader: 'js',
|
|
60
|
+
}));
|
|
61
|
+
// Control components are imported by NAME, and ESM named imports need the
|
|
62
|
+
// name to exist; the name is convention-derived from the filename
|
|
63
|
+
// (`clouds.controls.js` -> CloudsControls, `plasma.form.tsx` -> PlasmaForm),
|
|
64
|
+
// so the stub synthesizes exactly that export.
|
|
65
|
+
build.onLoad({ filter: /\.(form|controls)\.(tsx|jsx|ts|js)$/ }, (args) => {
|
|
66
|
+
const base = path.basename(args.path);
|
|
67
|
+
const stem = base.split('.')[0] ?? 'stub';
|
|
68
|
+
const kind = base.includes('.controls.') ? 'Controls' : 'Form';
|
|
69
|
+
const pascal = stem
|
|
70
|
+
.split('-')
|
|
71
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
72
|
+
.join('');
|
|
73
|
+
const name = `${pascal}${kind}`;
|
|
74
|
+
return {
|
|
75
|
+
contents: `export const ${name} = () => null;\nexport default ${name};`,
|
|
76
|
+
loader: 'js',
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
// Bun materializes plugin modules as ESM namespaces even when authored as
|
|
80
|
+
// CJS, so the path rides the default export; `import x from './x.webp'`
|
|
81
|
+
// unwraps it natively and the Asset shim's unwrap() handles the
|
|
82
|
+
// `require('./x.webp')` namespace form.
|
|
83
|
+
build.onLoad({ filter: /\.(webp|png|jpe?g|gif)$/ }, (args) => ({
|
|
84
|
+
contents: `export default ${JSON.stringify(args.path)};`,
|
|
85
|
+
loader: 'js',
|
|
86
|
+
}));
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Import the book and return its preset map. Accepts the conventional
|
|
92
|
+
* `export const presets` (the demo's shape) or a default export.
|
|
93
|
+
*/
|
|
94
|
+
export async function loadPresetBook(bookPath: string): Promise<Record<string, LoadedPreset>> {
|
|
95
|
+
const abs = path.resolve(bookPath);
|
|
96
|
+
const mod = (await import(abs)) as { presets?: unknown; default?: unknown };
|
|
97
|
+
const book = mod.presets ?? mod.default;
|
|
98
|
+
if (!book || typeof book !== 'object') {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${bookPath} did not export a preset book (expected \`export const presets = {...}\` or a default export).`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return book as Record<string, LoadedPreset>;
|
|
104
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
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();
|
|
Binary file
|
|
@@ -0,0 +1,259 @@
|
|
|
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
|
+
}
|