@celestia-island/hikari 0.55.65 → 0.55.66
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/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/theme/index.ts +13 -0
- package/src/theme/wallpaperShaderAliasGuard.test.ts +112 -0
- package/src/theme/wallpaperShaderPresets.test.ts +159 -0
- package/src/theme/wallpaperShaderPresets.ts +272 -0
- package/src/theme/wallpaperShaderRenderer.test.ts +292 -0
- package/src/theme/wallpaperShaderRenderer.ts +362 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -261,6 +261,18 @@ export {
|
|
|
261
261
|
type WallpaperPipelinePreset,
|
|
262
262
|
} from "./theme";
|
|
263
263
|
|
|
264
|
+
// Wallpaper shader layer: the shared WebGL2 pipeline renderer + the host
|
|
265
|
+
// preset registry. hikari ships the mechanism, never the GLSL — hosts
|
|
266
|
+
// register their generated fragments through registerShaderPresets and
|
|
267
|
+
// hand createWallpaperShaderSurface to HkWallpaperBackdrop's props bag.
|
|
268
|
+
export {
|
|
269
|
+
registerShaderPresets, getShaderPreset, listShaderPresetIds,
|
|
270
|
+
SHADER_VERTEX, WallpaperShaderPipeline, createWallpaperShaderSurface,
|
|
271
|
+
type WallpaperShaderPreset, type WallpaperShaderPresetInput,
|
|
272
|
+
type WallpaperShaderScaleConfig, type WallpaperShaderScaleInput,
|
|
273
|
+
type WallpaperShaderOverlayConfig,
|
|
274
|
+
} from "./theme";
|
|
275
|
+
|
|
264
276
|
// The wallpaper stack's SURFACE component. hikari does NOT put it on the
|
|
265
277
|
// `backdrop` decor floor (that floor has no unregister — a library-owned
|
|
266
278
|
// page-covering layer would be a decision the host cannot take back). The
|
package/src/theme/index.ts
CHANGED
|
@@ -64,3 +64,16 @@ export {
|
|
|
64
64
|
resolveThemeFollowSwitch, setWallpaperPipelineLookup, geo, currentPeriod,
|
|
65
65
|
} from "./useWallpaper";
|
|
66
66
|
export type { WallpaperInitConfig, WallpaperPipelineLookup, WallpaperPipelinePreset } from "./useWallpaper";
|
|
67
|
+
// Wallpaper shader layer. The presets are host assets registered at
|
|
68
|
+
// runtime — hikari ships the renderer and the registry, never any GLSL
|
|
69
|
+
// and never a consumer-side alias import (see wallpaperShaderAliasGuard).
|
|
70
|
+
export {
|
|
71
|
+
registerShaderPresets, getShaderPreset, listShaderPresetIds,
|
|
72
|
+
} from "./wallpaperShaderPresets";
|
|
73
|
+
export type {
|
|
74
|
+
WallpaperShaderPreset, WallpaperShaderPresetInput,
|
|
75
|
+
WallpaperShaderScaleConfig, WallpaperShaderScaleInput, WallpaperShaderOverlayConfig,
|
|
76
|
+
} from "./wallpaperShaderPresets";
|
|
77
|
+
export {
|
|
78
|
+
SHADER_VERTEX, WallpaperShaderPipeline, createWallpaperShaderSurface,
|
|
79
|
+
} from "./wallpaperShaderRenderer";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
// Why this guard exists: hikari ships TS source with ZERO path aliases.
|
|
7
|
+
// A module that imports a consumer-side alias (`@shaders/shaders`,
|
|
8
|
+
// `@wallpapers/wallpapers`, …) compiles fine inside the host that
|
|
9
|
+
// defines the alias and breaks every OTHER consumer's vite/tsconfig —
|
|
10
|
+
// the wallpaper shader adoption (#657's backdrop deliberately took its
|
|
11
|
+
// driver through a `createSurface` prop for exactly this reason). The
|
|
12
|
+
// shader modules added alongside this test are the first hikari modules
|
|
13
|
+
// that would be tempted; the rule is now mechanically enforced for the
|
|
14
|
+
// whole src tree.
|
|
15
|
+
|
|
16
|
+
const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
17
|
+
|
|
18
|
+
/** A quoted module specifier that resolves through a path alias — the
|
|
19
|
+
* form every alias import must take (static, dynamic, require-like). */
|
|
20
|
+
const ALIAS_SPECIFIER_RE = /["']@(?:shaders|wallpapers)\//;
|
|
21
|
+
|
|
22
|
+
/** Strip full-line `//` comments and `/* … */` blocks so prose and
|
|
23
|
+
* commented-out code do not count (registerAnimations.test.ts holds
|
|
24
|
+
* the same line for its keyframe scan). */
|
|
25
|
+
function stripComments(src: string): string {
|
|
26
|
+
return src
|
|
27
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
28
|
+
.replace(/^[ \t]*\/\/.*$/gm, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Lines of a source file that reference an alias specifier, in order. */
|
|
32
|
+
function aliasLines(src: string): string[] {
|
|
33
|
+
return src
|
|
34
|
+
.split("\n")
|
|
35
|
+
.filter((line) => ALIAS_SPECIFIER_RE.test(line))
|
|
36
|
+
.map((line) => line.trim());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Recursively collect TS/TSX/Vue sources under the package src tree.
|
|
40
|
+
* This test's own file is skipped: its positive-control fixtures are
|
|
41
|
+
* RUNTIME strings (not comments), and the guard would flag itself —
|
|
42
|
+
* the thing under test is never its own subject (registerAnimations
|
|
43
|
+
* holds the same line for the animation dir). */
|
|
44
|
+
function collectSourceFiles(dir: string): string[] {
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
47
|
+
const p = path.join(dir, entry.name);
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
50
|
+
out.push(...collectSourceFiles(p));
|
|
51
|
+
} else if (/\.(ts|tsx|vue)$/.test(entry.name)) {
|
|
52
|
+
if (p === path.resolve(fileURLToPath(import.meta.url))) continue;
|
|
53
|
+
out.push(p);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("hikari src carries no consumer-side alias imports", () => {
|
|
60
|
+
// ── Self-proofs (an extractive check with 0 hits must first prove it
|
|
61
|
+
// CAN hit — otherwise the pattern is silently broken and the guard
|
|
62
|
+
// is a tautology) ────────────────────────────────────────────────
|
|
63
|
+
it("self-proof: the matcher catches every alias-import shape it exists for", () => {
|
|
64
|
+
const positives = [
|
|
65
|
+
'import * as s from "@shaders/shaders";',
|
|
66
|
+
"import meta from '@shaders/themes.json';",
|
|
67
|
+
'import("@wallpapers/wallpapers").then((m) => m.pack());',
|
|
68
|
+
'const r = require("@shaders/shaders");',
|
|
69
|
+
];
|
|
70
|
+
for (const line of positives) {
|
|
71
|
+
expect(aliasLines(line)).toHaveLength(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("self-proof: comment stripping removes prose mentions without touching live code", () => {
|
|
76
|
+
const sample = [
|
|
77
|
+
"/** Chest's `@shaders/shaders` alias is consumer-side. */",
|
|
78
|
+
"// import x from \"@shaders/shaders\"; // retired experiment",
|
|
79
|
+
'import { onFrame } from "../runtime/animationBus";',
|
|
80
|
+
'import x from "@shaders/shaders";',
|
|
81
|
+
].join("\n");
|
|
82
|
+
const hits = aliasLines(stripComments(sample));
|
|
83
|
+
expect(hits).toEqual(['import x from "@shaders/shaders";']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("self-proof: the walk covers the tree and includes the shader modules", () => {
|
|
87
|
+
const files = collectSourceFiles(srcDir);
|
|
88
|
+
// The walk is not accidentally empty or rooted somewhere else: the
|
|
89
|
+
// baseline tree is hundreds of files and MUST contain the two
|
|
90
|
+
// modules this guard was written for (and, per the skip rule above,
|
|
91
|
+
// must NOT contain this test's own file).
|
|
92
|
+
expect(files.length).toBeGreaterThan(200);
|
|
93
|
+
const names = files.map((f) => path.basename(f));
|
|
94
|
+
for (const required of ["wallpaperShaderPresets.ts", "wallpaperShaderRenderer.ts"]) {
|
|
95
|
+
expect(names).toContain(required);
|
|
96
|
+
}
|
|
97
|
+
expect(names).not.toContain("wallpaperShaderAliasGuard.test.ts");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── The actual invariant ────────────────────────────────────────────
|
|
101
|
+
it("no src file references an @shaders/@wallpapers alias specifier", () => {
|
|
102
|
+
const offenders: string[] = [];
|
|
103
|
+
for (const file of collectSourceFiles(srcDir)) {
|
|
104
|
+
const stripped = stripComments(readFileSync(file, "utf-8"));
|
|
105
|
+
const hits = aliasLines(stripped);
|
|
106
|
+
if (hits.length > 0) {
|
|
107
|
+
offenders.push(`${path.relative(srcDir, file)}: ${hits[0]}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
expect(offenders).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getShaderPreset,
|
|
5
|
+
listShaderPresetIds,
|
|
6
|
+
registerShaderPresets,
|
|
7
|
+
} from "./wallpaperShaderPresets";
|
|
8
|
+
|
|
9
|
+
// NOTE (test-order): the "ships an empty table" block relies on running
|
|
10
|
+
// before any registration below; vitest executes describes in file order.
|
|
11
|
+
// Later blocks use dedicated ids so an ordering change cannot alias them.
|
|
12
|
+
describe("wallpaperShaderPresets builtin default", () => {
|
|
13
|
+
it("ships an empty table — hikari bundles no GLSL of its own", () => {
|
|
14
|
+
expect(listShaderPresetIds()).toEqual([]);
|
|
15
|
+
expect(getShaderPreset("omphalos.dark")).toBeNull();
|
|
16
|
+
expect(getShaderPreset("anything")).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("registerShaderPresets", () => {
|
|
21
|
+
it("registers a preset under its record key and fills `id` from it", () => {
|
|
22
|
+
registerShaderPresets({
|
|
23
|
+
"reg.full": {
|
|
24
|
+
fragment: "// frag",
|
|
25
|
+
texture: "data:image/webp;base64,zz",
|
|
26
|
+
render: { desktop: 1.5, mobile: "1.0" },
|
|
27
|
+
overlay: { light: "rgb(255 255 255 / 55%)", dark: "rgb(0 0 0 / 35%)" },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const preset = getShaderPreset("reg.full");
|
|
31
|
+
expect(preset).not.toBeNull();
|
|
32
|
+
expect(preset!.id).toBe("reg.full");
|
|
33
|
+
expect(preset!.fragment).toBe("// frag");
|
|
34
|
+
expect(preset!.texture).toBe("data:image/webp;base64,zz");
|
|
35
|
+
expect(preset!.overlay).toEqual({
|
|
36
|
+
light: "rgb(255 255 255 / 55%)",
|
|
37
|
+
dark: "rgb(0 0 0 / 35%)",
|
|
38
|
+
});
|
|
39
|
+
expect(preset!.render!.desktop).toBe(1.5);
|
|
40
|
+
expect(listShaderPresetIds()).toContain("reg.full");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("re-registering an id overrides the previous entry in place", () => {
|
|
44
|
+
registerShaderPresets({ "reg.over": { fragment: "// first" } });
|
|
45
|
+
registerShaderPresets({ "reg.over": { fragment: "// second" } });
|
|
46
|
+
const preset = getShaderPreset("reg.over");
|
|
47
|
+
expect(preset!.fragment).toBe("// second");
|
|
48
|
+
// Override, not append: the id appears exactly once.
|
|
49
|
+
expect(listShaderPresetIds().filter((id) => id === "reg.over")).toHaveLength(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("accepts prototype-member ids without shadowing anything", () => {
|
|
53
|
+
// A plain-object registry would let "toString" collide with the
|
|
54
|
+
// prototype member; the Map must hold it as an ordinary key.
|
|
55
|
+
registerShaderPresets({ toString: { fragment: "// literal key" } });
|
|
56
|
+
expect(getShaderPreset("toString")!.fragment).toBe("// literal key");
|
|
57
|
+
expect(listShaderPresetIds()).toContain("toString");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("throws loudly on an empty preset id", () => {
|
|
61
|
+
expect(() => registerShaderPresets({ "": { fragment: "// frag" } })).toThrow(
|
|
62
|
+
/empty id/,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("throws loudly on a missing or empty fragment", () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
registerShaderPresets({ "reg.bad": { fragment: "" } }),
|
|
69
|
+
).toThrow(/without a fragment shader/);
|
|
70
|
+
expect(() =>
|
|
71
|
+
registerShaderPresets({ "reg.bad2": {} as never }),
|
|
72
|
+
).toThrow(/without a fragment shader/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shallow-copies the overlay so later host mutation cannot desync the registry", () => {
|
|
76
|
+
const overlay = { light: "rgb(255 0 0 / 10%)", dark: "rgb(0 0 0 / 10%)" };
|
|
77
|
+
registerShaderPresets({ "reg.copy": { fragment: "// frag", overlay } });
|
|
78
|
+
overlay.light = "rgb(0 255 0 / 90%)";
|
|
79
|
+
expect(getShaderPreset("reg.copy")!.overlay!.light).toBe("rgb(255 0 0 / 10%)");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("mobile scale formula evaluation", () => {
|
|
84
|
+
it("evaluates an arithmetic formula with the live aspect", () => {
|
|
85
|
+
registerShaderPresets({
|
|
86
|
+
"scale.formula": {
|
|
87
|
+
fragment: "// frag",
|
|
88
|
+
render: { desktop: 1.8, mobile: "Math.min(4.0, 2.88 / aspect)" },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const render = getShaderPreset("scale.formula")!.render!;
|
|
92
|
+
expect(render.desktop).toBe(1.8);
|
|
93
|
+
// Below aspect 0.72 the raw curve (2.88 / aspect) exceeds the 4.0
|
|
94
|
+
// ceiling and clamps — 2.88 / 0.45 = 6.4 → 4.0.
|
|
95
|
+
expect(render.mobile(0.45)).toBe(4.0);
|
|
96
|
+
expect(render.mobile(0.8)).toBeCloseTo(2.88 / 0.8, 10);
|
|
97
|
+
// Wide aspects fall below the ceiling and follow the raw curve.
|
|
98
|
+
expect(render.mobile(2.0)).toBeCloseTo(2.88 / 2.0, 10);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("evaluates plain arithmetic without function calls", () => {
|
|
102
|
+
registerShaderPresets({
|
|
103
|
+
"scale.arith": {
|
|
104
|
+
fragment: "// frag",
|
|
105
|
+
render: { desktop: 1.2, mobile: "1.0 + 0.5 * aspect" },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const render = getShaderPreset("scale.arith")!.render!;
|
|
109
|
+
expect(render.mobile(0.5)).toBeCloseTo(1.25, 10);
|
|
110
|
+
expect(render.mobile(0.72)).toBeCloseTo(1.36, 10);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("falls back to the constant desktop scale when the charset is rejected", () => {
|
|
114
|
+
registerShaderPresets({
|
|
115
|
+
"scale.charset": {
|
|
116
|
+
fragment: "// frag",
|
|
117
|
+
render: { desktop: 0.9, mobile: "aspect ^ 2" },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
const render = getShaderPreset("scale.charset")!.render!;
|
|
121
|
+
expect(render.mobile(0.3)).toBe(0.9);
|
|
122
|
+
expect(render.mobile(2.0)).toBe(0.9);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("falls back to the constant desktop scale when the formula parses to NaN", () => {
|
|
126
|
+
// Passes the charset gate but the evaluator cannot parse it
|
|
127
|
+
// (function-call syntax) — the Number.isFinite guard carried from
|
|
128
|
+
// chest's registry keeps NaN out of the u_scale uniform.
|
|
129
|
+
registerShaderPresets({
|
|
130
|
+
"scale.nan": {
|
|
131
|
+
fragment: "// frag",
|
|
132
|
+
render: { desktop: 1.4, mobile: "Math.pow(aspect, 2)" },
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const render = getShaderPreset("scale.nan")!.render!;
|
|
136
|
+
expect(render.mobile(0.5)).toBe(1.4);
|
|
137
|
+
expect(render.mobile(1.7)).toBe(1.4);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("accepts a host closure and gates its NaN through the same finite guard", () => {
|
|
141
|
+
registerShaderPresets({
|
|
142
|
+
"scale.fn": {
|
|
143
|
+
fragment: "// frag",
|
|
144
|
+
render: {
|
|
145
|
+
desktop: 1.8,
|
|
146
|
+
mobile: (a) => (a > 0 ? 2.88 / a : Number.NaN),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const render = getShaderPreset("scale.fn")!.render!;
|
|
151
|
+
expect(render.mobile(0.5)).toBeCloseTo(5.76, 10);
|
|
152
|
+
expect(render.mobile(-1)).toBe(1.8);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("leaves no render config when the host declares none", () => {
|
|
156
|
+
registerShaderPresets({ "scale.none": { fragment: "// frag" } });
|
|
157
|
+
expect(getShaderPreset("scale.none")!.render).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallpaper shader preset registry — the HOST-facing half of the pipeline
|
|
3
|
+
* wallpaper layer. A host registers its fragment shaders (plus optional
|
|
4
|
+
* texture / scale / overlay config) under runtime preset ids; the shared
|
|
5
|
+
* renderer (`wallpaperShaderRenderer.ts`) and the wallpaper logic layer
|
|
6
|
+
* (via `WallpaperPipelineLookup`) resolve those ids through this registry.
|
|
7
|
+
*
|
|
8
|
+
* Why a registry and not an import: hikari ships TS source with zero path
|
|
9
|
+
* aliases, and shader sources are application assets that live in each
|
|
10
|
+
* host's generated bundle (chest's `@shaders/shaders`, erp's
|
|
11
|
+
* `.generated/shaders`) — an alias import here would force every consumer
|
|
12
|
+
* to configure the same vite/tsconfig mapping. The same design language as
|
|
13
|
+
* `registerTokenGroup` / `registerThemeDecor`: hikari owns the mechanism,
|
|
14
|
+
* the host owns the content, and the built-in default is an EMPTY table —
|
|
15
|
+
* hikari deliberately bundles no GLSL of its own.
|
|
16
|
+
*
|
|
17
|
+
* Hard rules this module holds:
|
|
18
|
+
*
|
|
19
|
+
* - **Zero alias imports.** Nothing here may reference `@shaders/*`,
|
|
20
|
+
* `@wallpapers/*` or any other consumer-side alias (guarded by
|
|
21
|
+
* `wallpaperShaderAliasGuard.test.ts`).
|
|
22
|
+
* - **Registration is not a side effect of importing.** A host that never
|
|
23
|
+
* calls `registerShaderPresets` gets an untouched (empty) registry and
|
|
24
|
+
* every pipeline lookup degrades to `null` — the "no pipeline
|
|
25
|
+
* wallpapers" state the backdrop renders as the solid floor.
|
|
26
|
+
* - **Invalid input fails loudly** (themeDecor's rule): an empty preset
|
|
27
|
+
* id or an empty fragment throws at registration time. The silent
|
|
28
|
+
* alternative is a wallpaper that renders nothing with no error
|
|
29
|
+
* anywhere.
|
|
30
|
+
* - **Re-registration overrides.** Registering the same id again
|
|
31
|
+
* replaces the previous entry — the escape hatch for hosts that swap
|
|
32
|
+
* preset tables at runtime (a fresh codegen, an A/B experiment).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Normalized render-scale config: flat on landscape, a curve on portrait. */
|
|
36
|
+
export interface WallpaperShaderScaleConfig {
|
|
37
|
+
/** Uniform value at aspect >= 1.0. */
|
|
38
|
+
desktop: number;
|
|
39
|
+
/** Uniform value below aspect 1.0, given the live canvas aspect. */
|
|
40
|
+
mobile: (aspect: number) => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Host input for `render`: the mobile scale as a safe-evaluated formula
|
|
44
|
+
* string (the generated theme manifests carry `"Math.min(4.0, 2.88 /
|
|
45
|
+
* aspect)"`-style expressions) or a ready-made closure. */
|
|
46
|
+
export interface WallpaperShaderScaleInput {
|
|
47
|
+
desktop: number;
|
|
48
|
+
mobile: string | ((aspect: number) => number);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Scrim colors the host's page CSS mixes over the canvas, per mode. */
|
|
52
|
+
export interface WallpaperShaderOverlayConfig {
|
|
53
|
+
light: string;
|
|
54
|
+
dark: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a host hands to `registerShaderPresets` — the record KEY is the
|
|
58
|
+
* preset id (`omphalos.dark`, `single`, …); no `id` field to drift. */
|
|
59
|
+
export interface WallpaperShaderPresetInput {
|
|
60
|
+
/** Fragment-shader source (GLSL ES 3.00). Must be non-empty. */
|
|
61
|
+
fragment: string;
|
|
62
|
+
/** Optional texture URL (data: or asset import) bound to `u_texture`. */
|
|
63
|
+
texture?: string;
|
|
64
|
+
/** Optional scale config (see `WallpaperShaderScaleInput`). */
|
|
65
|
+
render?: WallpaperShaderScaleInput;
|
|
66
|
+
/** Optional overlay colors for the host's scrim. */
|
|
67
|
+
overlay?: WallpaperShaderOverlayConfig;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The stored preset `getShaderPreset` resolves to — the input with `id`
|
|
71
|
+
* filled from the registration key and `render` normalized to a
|
|
72
|
+
* `WallpaperShaderScaleConfig`. */
|
|
73
|
+
export interface WallpaperShaderPreset {
|
|
74
|
+
id: string;
|
|
75
|
+
fragment: string;
|
|
76
|
+
texture?: string;
|
|
77
|
+
render?: WallpaperShaderScaleConfig;
|
|
78
|
+
overlay?: WallpaperShaderOverlayConfig;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The registry itself. A Map (not a plain object) so preset ids cannot
|
|
82
|
+
// collide with Object.prototype members ("constructor", "toString", …) —
|
|
83
|
+
// a generated manifest containing such an id would otherwise shadow a
|
|
84
|
+
// prototype member and misbehave in surprising ways.
|
|
85
|
+
const shaderPresets = new Map<string, WallpaperShaderPreset>();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Safe arithmetic evaluator for mobile-scale formula strings — a
|
|
89
|
+
* hand-rolled recursive-descent parser, NOT `eval`/`Function`: generated
|
|
90
|
+
* theme manifests are build inputs, and feeding those to `eval` would
|
|
91
|
+
* make one poisoned codegen artifact a code-execution vector.
|
|
92
|
+
*
|
|
93
|
+
* Supported grammar: `+ - * /`, parentheses, unary minus, number
|
|
94
|
+
* literals, the registered variable names (the live aspect), and exactly
|
|
95
|
+
* `Math.min(a, b)` / `Math.max(a, b)` as the only function calls.
|
|
96
|
+
*/
|
|
97
|
+
function safeEvalArithmetic(
|
|
98
|
+
expr: string,
|
|
99
|
+
vars: Record<string, number>,
|
|
100
|
+
): number {
|
|
101
|
+
const s = expr.trim();
|
|
102
|
+
let pos = 0;
|
|
103
|
+
|
|
104
|
+
function skip() {
|
|
105
|
+
while (pos < s.length && s[pos] === " ") pos++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseAtom(): number {
|
|
109
|
+
skip();
|
|
110
|
+
if (s[pos] === "(") {
|
|
111
|
+
pos++;
|
|
112
|
+
const v = parseExpr();
|
|
113
|
+
skip();
|
|
114
|
+
pos++;
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
for (const name of ["Math.min", "Math.max"] as const) {
|
|
118
|
+
if (s.slice(pos, pos + name.length) === name) {
|
|
119
|
+
pos += name.length;
|
|
120
|
+
skip();
|
|
121
|
+
pos++;
|
|
122
|
+
const a = parseExpr();
|
|
123
|
+
skip();
|
|
124
|
+
pos++;
|
|
125
|
+
const b = parseExpr();
|
|
126
|
+
skip();
|
|
127
|
+
pos++;
|
|
128
|
+
return name === "Math.min" ? Math.min(a, b) : Math.max(a, b);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const [name, val] of Object.entries(vars)) {
|
|
132
|
+
if (
|
|
133
|
+
s.slice(pos, pos + name.length) === name &&
|
|
134
|
+
!/[a-zA-Z0-9_]/.test(s[pos + name.length] ?? "")
|
|
135
|
+
) {
|
|
136
|
+
pos += name.length;
|
|
137
|
+
return val;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const start = pos;
|
|
141
|
+
while (pos < s.length && /[\d.]/.test(s[pos])) pos++;
|
|
142
|
+
return parseFloat(s.slice(start, pos));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseUnary(): number {
|
|
146
|
+
skip();
|
|
147
|
+
if (s[pos] === "-") {
|
|
148
|
+
pos++;
|
|
149
|
+
return -parseUnary();
|
|
150
|
+
}
|
|
151
|
+
return parseAtom();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseMul(): number {
|
|
155
|
+
let left = parseUnary();
|
|
156
|
+
skip();
|
|
157
|
+
while (pos < s.length && (s[pos] === "*" || s[pos] === "/")) {
|
|
158
|
+
const op = s[pos++];
|
|
159
|
+
const right = parseUnary();
|
|
160
|
+
left = op === "*" ? left * right : left / right;
|
|
161
|
+
skip();
|
|
162
|
+
}
|
|
163
|
+
return left;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseExpr(): number {
|
|
167
|
+
let left = parseMul();
|
|
168
|
+
skip();
|
|
169
|
+
while (pos < s.length && (s[pos] === "+" || s[pos] === "-")) {
|
|
170
|
+
const op = s[pos++];
|
|
171
|
+
const right = parseMul();
|
|
172
|
+
left = op === "+" ? left + right : left - right;
|
|
173
|
+
skip();
|
|
174
|
+
}
|
|
175
|
+
return left;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return parseExpr();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The sanctioned charset for a formula string — reject anything outside
|
|
182
|
+
* arithmetic, parentheses, whitespace and identifier letters up front. */
|
|
183
|
+
const FORMULA_CHARSET = /^[\d\s+\-*/().,a-zA-Z]+$/;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Normalize a host's `render` input into the stored scale config.
|
|
187
|
+
*
|
|
188
|
+
* Two fallback tiers, both degrading to the constant desktop scale (never
|
|
189
|
+
* to NaN — a NaN `u_scale` poisons the vertex math and the canvas goes
|
|
190
|
+
* black):
|
|
191
|
+
*
|
|
192
|
+
* 1. a formula string with characters outside the arithmetic charset is
|
|
193
|
+
* rejected unread;
|
|
194
|
+
* 2. a formula that passes the charset gate but still fails to parse
|
|
195
|
+
* (`Math.pow(aspect, 2)`) evaluates to NaN and is dropped at call
|
|
196
|
+
* time (the `Number.isFinite` guard carried from chest's registry).
|
|
197
|
+
*/
|
|
198
|
+
function normalizeScaleConfig(cfg: WallpaperShaderScaleInput): WallpaperShaderScaleConfig {
|
|
199
|
+
if (typeof cfg.mobile === "function") {
|
|
200
|
+
// A host-provided closure needs no evaluation — but its output still
|
|
201
|
+
// must not leak NaN into the uniform, so it gets the same finite gate.
|
|
202
|
+
const mobileFn = cfg.mobile;
|
|
203
|
+
return {
|
|
204
|
+
desktop: cfg.desktop,
|
|
205
|
+
mobile: (a) => {
|
|
206
|
+
const v = mobileFn(a);
|
|
207
|
+
return Number.isFinite(v) ? v : cfg.desktop;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const mobileSrc = cfg.mobile.trim();
|
|
212
|
+
if (!FORMULA_CHARSET.test(mobileSrc)) {
|
|
213
|
+
return { desktop: cfg.desktop, mobile: () => cfg.desktop };
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
desktop: cfg.desktop,
|
|
217
|
+
mobile: (a) => {
|
|
218
|
+
const v = safeEvalArithmetic(mobileSrc, { aspect: a });
|
|
219
|
+
return Number.isFinite(v) ? v : cfg.desktop;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function assertPresetInput(id: string, input: WallpaperShaderPresetInput): void {
|
|
225
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
226
|
+
throw new Error(`[wallpaperShaderPresets] preset registered with an empty id`);
|
|
227
|
+
}
|
|
228
|
+
if (!input || typeof input.fragment !== "string" || input.fragment.length === 0) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`[wallpaperShaderPresets] preset "${id}" registered without a fragment shader`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Register (or override) wallpaper shader presets. The record key is the
|
|
237
|
+
* runtime preset id — dual-mode themes register two entries
|
|
238
|
+
* (`"omphalos.dark"`, `"omphalos.light"`); the wallpaper logic layer
|
|
239
|
+
* resolves the mode suffix itself before looking up.
|
|
240
|
+
*
|
|
241
|
+
* Must run before the wallpaper state hydrates for the stored ids to
|
|
242
|
+
* resolve (host boot order: register → `initWallpaper`), same as
|
|
243
|
+
* `registerWallpaperPack`.
|
|
244
|
+
*/
|
|
245
|
+
export function registerShaderPresets(presets: Record<string, WallpaperShaderPresetInput>): void {
|
|
246
|
+
for (const [id, input] of Object.entries(presets ?? {})) {
|
|
247
|
+
assertPresetInput(id, input);
|
|
248
|
+
shaderPresets.set(id, {
|
|
249
|
+
id,
|
|
250
|
+
fragment: input.fragment,
|
|
251
|
+
...(input.texture !== undefined && input.texture !== null
|
|
252
|
+
? { texture: input.texture }
|
|
253
|
+
: {}),
|
|
254
|
+
...(input.render ? { render: normalizeScaleConfig(input.render) } : {}),
|
|
255
|
+
// Shallow-copied so later host mutation cannot desync the registry
|
|
256
|
+
// (themeDecor holds the same line for its props bag).
|
|
257
|
+
...(input.overlay ? { overlay: { ...input.overlay } } : {}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Resolve a preset id, or `null` when nothing is registered under it —
|
|
263
|
+
* the value the backdrop's pipeline branch stands down on. */
|
|
264
|
+
export function getShaderPreset(id: string): WallpaperShaderPreset | null {
|
|
265
|
+
return shaderPresets.get(id) ?? null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Every registered preset id, in registration order — introspection for
|
|
269
|
+
* hosts and tests; mutation goes through `registerShaderPresets`. */
|
|
270
|
+
export function listShaderPresetIds(): string[] {
|
|
271
|
+
return [...shaderPresets.keys()];
|
|
272
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { HkWallpaperSurfaceContext } from "../components/HkWallpaperBackdrop";
|
|
4
|
+
import { registerShaderPresets } from "./wallpaperShaderPresets";
|
|
5
|
+
import {
|
|
6
|
+
WallpaperShaderPipeline,
|
|
7
|
+
createWallpaperShaderSurface,
|
|
8
|
+
} from "./wallpaperShaderRenderer";
|
|
9
|
+
|
|
10
|
+
// WallpaperShaderPipeline.attach() compiles real GLSL through WebGL2;
|
|
11
|
+
// happy-dom has no WebGL at all, so the GPU side is stubbed at its seam —
|
|
12
|
+
// the preset registry — and only the pure selection logic runs against
|
|
13
|
+
// real numbers. (The WebGL2-unavailable branch itself lives in
|
|
14
|
+
// HkWallpaperBackdrop, which owns the context probe now.)
|
|
15
|
+
registerShaderPresets({
|
|
16
|
+
configured: {
|
|
17
|
+
fragment: "// frag",
|
|
18
|
+
render: { desktop: 2.5, mobile: (a) => 10 * a },
|
|
19
|
+
},
|
|
20
|
+
bare: { fragment: "// frag" },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Private members the render loop reads; reached structurally, no `any`. */
|
|
24
|
+
type PipelineInternals = {
|
|
25
|
+
canvas: HTMLCanvasElement | null;
|
|
26
|
+
currentPreset: string;
|
|
27
|
+
computeScale(): number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const internals = (p: WallpaperShaderPipeline): PipelineInternals =>
|
|
31
|
+
p as unknown as PipelineInternals;
|
|
32
|
+
|
|
33
|
+
/** Bind a preset + canvas size, then read the scale the shader would get. */
|
|
34
|
+
function scaleFor(presetId: string, width: number, height: number): number {
|
|
35
|
+
const p = new WallpaperShaderPipeline();
|
|
36
|
+
const i = internals(p);
|
|
37
|
+
i.currentPreset = presetId;
|
|
38
|
+
const canvas = document.createElement("canvas");
|
|
39
|
+
canvas.width = width;
|
|
40
|
+
canvas.height = height;
|
|
41
|
+
i.canvas = canvas;
|
|
42
|
+
return i.computeScale();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal gl stub for the failure paths: clearColor no-ops, the first
|
|
46
|
+
* createShader answers null so createProgram bails before any real GL. */
|
|
47
|
+
const failingGl = () =>
|
|
48
|
+
({ clearColor: () => {}, createShader: () => null }) as unknown as WebGL2RenderingContext;
|
|
49
|
+
|
|
50
|
+
const ctx = (overrides: Partial<HkWallpaperSurfaceContext> = {}): HkWallpaperSurfaceContext => ({
|
|
51
|
+
canvas: document.createElement("canvas"),
|
|
52
|
+
gl: failingGl(),
|
|
53
|
+
presetId: "configured",
|
|
54
|
+
period: "day",
|
|
55
|
+
powerPreference: "high-performance",
|
|
56
|
+
...overrides,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("createWallpaperShaderSurface (HkWallpaperBackdrop driver)", () => {
|
|
60
|
+
/** A canvas attached to the document, as the component hands it over —
|
|
61
|
+
* the driver must never remove it (the component owns the DOM). */
|
|
62
|
+
const ownedCanvas = (): HTMLCanvasElement => {
|
|
63
|
+
const canvas = document.createElement("canvas");
|
|
64
|
+
document.body.append(canvas);
|
|
65
|
+
return canvas;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
it("declines (null) on an unknown preset without touching the canvas", () => {
|
|
69
|
+
const canvas = ownedCanvas();
|
|
70
|
+
expect(createWallpaperShaderSurface(ctx({ presetId: "unknown", canvas }))).toBeNull();
|
|
71
|
+
expect(canvas.isConnected).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("declines (null) when the program cannot build, leaving the canvas in place", () => {
|
|
75
|
+
const canvas = ownedCanvas();
|
|
76
|
+
expect(createWallpaperShaderSurface(ctx({ canvas }))).toBeNull();
|
|
77
|
+
expect(canvas.isConnected).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("delegates setPeriod/dispose to one pipeline per surface", async () => {
|
|
81
|
+
const attachSpy = vi
|
|
82
|
+
.spyOn(WallpaperShaderPipeline.prototype, "attach")
|
|
83
|
+
.mockReturnValue(true);
|
|
84
|
+
const setPeriodSpy = vi.spyOn(WallpaperShaderPipeline.prototype, "setPeriod").mockImplementation(() => {});
|
|
85
|
+
const destroySpy = vi.spyOn(WallpaperShaderPipeline.prototype, "destroy").mockImplementation(() => {});
|
|
86
|
+
|
|
87
|
+
const surface = createWallpaperShaderSurface(ctx());
|
|
88
|
+
expect(surface).not.toBeNull();
|
|
89
|
+
expect(attachSpy).toHaveBeenCalledTimes(1);
|
|
90
|
+
|
|
91
|
+
surface!.setPeriod?.("night");
|
|
92
|
+
expect(setPeriodSpy).toHaveBeenCalledWith("night");
|
|
93
|
+
|
|
94
|
+
surface!.dispose();
|
|
95
|
+
expect(destroySpy).toHaveBeenCalledTimes(1);
|
|
96
|
+
|
|
97
|
+
attachSpy.mockRestore();
|
|
98
|
+
setPeriodSpy.mockRestore();
|
|
99
|
+
destroySpy.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("attach failure modes", () => {
|
|
104
|
+
it("rolls back to idle on failure", () => {
|
|
105
|
+
const p = new WallpaperShaderPipeline();
|
|
106
|
+
expect(p.attach(ctx().canvas, failingGl(), "configured", "night")).toBe(false);
|
|
107
|
+
expect(p.isActive).toBe(false);
|
|
108
|
+
expect(p.presetId).toBe("");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("WallpaperShaderPipeline.computeScale (render-config selection)", () => {
|
|
113
|
+
it("routes portrait aspects through the mobile curve with the real ratio", () => {
|
|
114
|
+
// 1080×2400 phone → aspect 0.45; the stub mobile curve (10 × aspect)
|
|
115
|
+
// echoes the exact ratio the pipeline derived from the canvas.
|
|
116
|
+
expect(scaleFor("configured", 1080, 2400)).toBeCloseTo(4.5, 10);
|
|
117
|
+
// 999×1000 → aspect 0.999: still strictly below 1.0, still mobile.
|
|
118
|
+
expect(scaleFor("configured", 999, 1000)).toBeCloseTo(9.99, 10);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("uses the flat desktop scale at aspect 1.0 and above", () => {
|
|
122
|
+
// Exactly square is desktop (the mobile branch is aspect < 1.0 only).
|
|
123
|
+
expect(scaleFor("configured", 1000, 1000)).toBe(2.5);
|
|
124
|
+
// 16:9 and 16:9-at-4K landscape monitors.
|
|
125
|
+
expect(scaleFor("configured", 1920, 1080)).toBe(2.5);
|
|
126
|
+
expect(scaleFor("configured", 3840, 2160)).toBe(2.5);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("falls back to 1.0 without a canvas, a render config, or a preset", () => {
|
|
130
|
+
const p = new WallpaperShaderPipeline();
|
|
131
|
+
const i = internals(p);
|
|
132
|
+
i.currentPreset = "configured";
|
|
133
|
+
i.canvas = null; // never attached / already destroyed
|
|
134
|
+
expect(i.computeScale()).toBe(1.0);
|
|
135
|
+
// A preset without a render config keeps the neutral scale.
|
|
136
|
+
expect(scaleFor("bare", 1080, 2400)).toBe(1.0);
|
|
137
|
+
// An unknown preset id likewise.
|
|
138
|
+
expect(scaleFor("unknown", 1920, 1080)).toBe(1.0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── Resize policy (2026-09-21 phone-IME flicker mitigation) ──────────
|
|
143
|
+
//
|
|
144
|
+
// The drawing buffer sizes against the LAYOUT viewport
|
|
145
|
+
// (documentElement client size) and reallocs only after the viewport
|
|
146
|
+
// settles (RESIZE_SETTLE_MS debounce): keyboard animations step the
|
|
147
|
+
// visual viewport many times without moving the canvas' CSS box
|
|
148
|
+
// (fixed, 100% of the ICB), and each intermediate realloc discarded the
|
|
149
|
+
// GPU texture → black flash. These tests pin both halves.
|
|
150
|
+
type PipelineResizeInternals = {
|
|
151
|
+
canvas: HTMLCanvasElement | null;
|
|
152
|
+
gl: WebGL2RenderingContext | null;
|
|
153
|
+
onResize: () => void;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
describe("WallpaperShaderPipeline resize policy", () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
vi.useFakeTimers();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const restoreFns: Array<() => void> = [];
|
|
162
|
+
|
|
163
|
+
/** Override a getter with a fixed value; restored in afterEach. */
|
|
164
|
+
function stubGetter(obj: object, prop: string, value: number): void {
|
|
165
|
+
const desc = Object.getOwnPropertyDescriptor(obj, prop);
|
|
166
|
+
Object.defineProperty(obj, prop, {
|
|
167
|
+
configurable: true,
|
|
168
|
+
get: () => value,
|
|
169
|
+
});
|
|
170
|
+
restoreFns.push(() => {
|
|
171
|
+
if (desc) Object.defineProperty(obj, prop, desc);
|
|
172
|
+
else delete (obj as Record<string, unknown>)[prop];
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stubViewport(opts: {
|
|
177
|
+
innerWidth?: number;
|
|
178
|
+
innerHeight?: number;
|
|
179
|
+
icbWidth: number;
|
|
180
|
+
icbHeight: number;
|
|
181
|
+
dpr?: number;
|
|
182
|
+
}): void {
|
|
183
|
+
if (opts.innerWidth !== undefined) stubGetter(window, "innerWidth", opts.innerWidth);
|
|
184
|
+
if (opts.innerHeight !== undefined) stubGetter(window, "innerHeight", opts.innerHeight);
|
|
185
|
+
if (opts.dpr !== undefined) stubGetter(window, "devicePixelRatio", opts.dpr);
|
|
186
|
+
stubGetter(document.documentElement, "clientWidth", opts.icbWidth);
|
|
187
|
+
stubGetter(document.documentElement, "clientHeight", opts.icbHeight);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Bind a canvas + truthy gl onto the pipeline (resize gates on both). */
|
|
191
|
+
function bind(): { p: PipelineResizeInternals; canvas: HTMLCanvasElement } {
|
|
192
|
+
const p = new WallpaperShaderPipeline() as unknown as PipelineResizeInternals;
|
|
193
|
+
const canvas = document.createElement("canvas");
|
|
194
|
+
p.canvas = canvas;
|
|
195
|
+
p.gl = {} as WebGL2RenderingContext;
|
|
196
|
+
return { p, canvas };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Count writes to the canvas backing-store width (realloc proxy). */
|
|
200
|
+
function countWidthWrites(canvas: HTMLCanvasElement): () => number {
|
|
201
|
+
const desc = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "width")!;
|
|
202
|
+
let n = 0;
|
|
203
|
+
Object.defineProperty(canvas, "width", {
|
|
204
|
+
configurable: true,
|
|
205
|
+
get: () => desc.get!.call(canvas) as number,
|
|
206
|
+
set: (v: number) => {
|
|
207
|
+
n += 1;
|
|
208
|
+
desc.set!.call(canvas, v);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
restoreFns.push(() => delete (canvas as unknown as Record<string, unknown>).width);
|
|
212
|
+
return () => n;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
vi.useRealTimers();
|
|
217
|
+
while (restoreFns.length > 0) restoreFns.pop()!();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("sizes the buffer from the layout viewport, not the visual one", () => {
|
|
221
|
+
// Keyboard open (resizes-visual engines): window.innerHeight
|
|
222
|
+
// shrinks to 400 while the ICB stays 390×844.
|
|
223
|
+
stubViewport({ innerWidth: 390, innerHeight: 400, icbWidth: 390, icbHeight: 844, dpr: 2 });
|
|
224
|
+
const { p, canvas } = bind();
|
|
225
|
+
// Buffer already at the ICB-derived size (dpr 2, scale 0.5 → 1:1).
|
|
226
|
+
canvas.width = 390;
|
|
227
|
+
canvas.height = 844;
|
|
228
|
+
const writes = countWidthWrites(canvas);
|
|
229
|
+
|
|
230
|
+
p.onResize();
|
|
231
|
+
vi.advanceTimersByTime(1000);
|
|
232
|
+
|
|
233
|
+
// The CSS box never moved → no realloc, no GPU-texture discard.
|
|
234
|
+
expect(writes()).toBe(0);
|
|
235
|
+
expect(canvas.width).toBe(390);
|
|
236
|
+
expect(canvas.height).toBe(844);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("reallocs once, after the settle window, on a real geometry change", () => {
|
|
240
|
+
stubViewport({ icbWidth: 390, icbHeight: 700, dpr: 2 });
|
|
241
|
+
const { p, canvas } = bind();
|
|
242
|
+
canvas.width = 390;
|
|
243
|
+
canvas.height = 844;
|
|
244
|
+
const writes = countWidthWrites(canvas);
|
|
245
|
+
|
|
246
|
+
p.onResize();
|
|
247
|
+
expect(writes()).toBe(0); // nothing before the settle window
|
|
248
|
+
vi.advanceTimersByTime(140);
|
|
249
|
+
expect(writes()).toBe(1);
|
|
250
|
+
expect(canvas.width).toBe(390);
|
|
251
|
+
expect(canvas.height).toBe(700);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("coalesces a resize storm into exactly one realloc", () => {
|
|
255
|
+
// Five stepped viewport sizes (keyboard/drag animation) inside the
|
|
256
|
+
// settle window must end in ONE final realloc, not five.
|
|
257
|
+
const { p, canvas } = bind();
|
|
258
|
+
canvas.width = 390;
|
|
259
|
+
canvas.height = 844;
|
|
260
|
+
const writes = countWidthWrites(canvas);
|
|
261
|
+
|
|
262
|
+
const heights = [820, 800, 780, 760, 700];
|
|
263
|
+
for (const h of heights) {
|
|
264
|
+
stubViewport({ icbWidth: 390, icbHeight: h, dpr: 2 });
|
|
265
|
+
p.onResize();
|
|
266
|
+
vi.advanceTimersByTime(50); // below the settle window every time
|
|
267
|
+
}
|
|
268
|
+
expect(writes()).toBe(0);
|
|
269
|
+
vi.advanceTimersByTime(140);
|
|
270
|
+
expect(writes()).toBe(1);
|
|
271
|
+
expect(canvas.height).toBe(700);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("cancels a pending realloc when the surface is disposed", () => {
|
|
275
|
+
stubViewport({ icbWidth: 390, icbHeight: 700, dpr: 2 });
|
|
276
|
+
const { p, canvas } = bind();
|
|
277
|
+
canvas.width = 390;
|
|
278
|
+
canvas.height = 844;
|
|
279
|
+
const writes = countWidthWrites(canvas);
|
|
280
|
+
|
|
281
|
+
p.onResize();
|
|
282
|
+
expect(vi.getTimerCount()).toBe(1); // the settle timer is armed
|
|
283
|
+
(p as unknown as WallpaperShaderPipeline).destroy();
|
|
284
|
+
// destroy must CLEAR the timer, not merely orphan it: a live timer
|
|
285
|
+
// that no-ops against a nulled canvas still holds the event loop
|
|
286
|
+
// hostage in embedders and reads as "cancelled" to a canvas-write
|
|
287
|
+
// probe — the timer count is the load-bearing probe.
|
|
288
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
289
|
+
vi.advanceTimersByTime(1000);
|
|
290
|
+
expect(writes()).toBe(0);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { onFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
2
|
+
import type {
|
|
3
|
+
HkWallpaperSurface,
|
|
4
|
+
HkWallpaperSurfaceContext,
|
|
5
|
+
HkWallpaperSurfaceFactory,
|
|
6
|
+
} from "../components/HkWallpaperBackdrop";
|
|
7
|
+
import type { TimePeriod } from "./useSolarTime";
|
|
8
|
+
import { getShaderPreset } from "./wallpaperShaderPresets";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The shared WebGL2 pipeline renderer for wallpaper backdrops — the
|
|
12
|
+
* attach()-form driver `HkWallpaperBackdrop` hands a canvas to. Ported
|
|
13
|
+
* from the per-app copies chest (#1123) and erp (#123) carried after the
|
|
14
|
+
* component-layer adoption; those two repos kept identical 280-line
|
|
15
|
+
* renderers, which is exactly the duplication this module retires.
|
|
16
|
+
*
|
|
17
|
+
* The shape is the surface contract, not the pre-component singleton:
|
|
18
|
+
*
|
|
19
|
+
* - the COMPONENT owns the canvas and the GL context (it created the
|
|
20
|
+
* context with the `powerPreference` it was configured with — that
|
|
21
|
+
* attribute was consumed at `getContext` time and is therefore not
|
|
22
|
+
* something this driver passes again; it rides
|
|
23
|
+
* `HkWallpaperSurfaceContext` for factories that create their own
|
|
24
|
+
* context);
|
|
25
|
+
* - ONE driver instance per attach — the module-level singleton both
|
|
26
|
+
* forks once kept was the two-layouts-share-one-canvas bug the
|
|
27
|
+
* component layer was built to kill (either layout's teardown
|
|
28
|
+
* destroyed the other's pipeline);
|
|
29
|
+
* - `attach()` never writes the DOM and never removes the canvas on
|
|
30
|
+
* failure — it returns `false` fully rolled back, and the component
|
|
31
|
+
* stands down to the solid floor.
|
|
32
|
+
*
|
|
33
|
+
* Uniform contract (what a registered fragment shader may rely on):
|
|
34
|
+
* `u_time` (seconds since attach), `u_resolution` (drawing-buffer px),
|
|
35
|
+
* `u_scale` (see `WallpaperShaderScaleConfig`), `u_period` (0 day,
|
|
36
|
+
* 1 dusk, 2 night) and `u_texture` (sampler2D, unit 0 — bound only when
|
|
37
|
+
* the preset declares a texture). Attributes: `a_position` (fullscreen
|
|
38
|
+
* quad, triangle strip).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** The fixed vertex stage every preset fragment pairs with. */
|
|
42
|
+
export const SHADER_VERTEX = `#version 300 es
|
|
43
|
+
in vec2 a_position;
|
|
44
|
+
out vec2 v_position;
|
|
45
|
+
void main() {
|
|
46
|
+
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
47
|
+
v_position = a_position;
|
|
48
|
+
}`;
|
|
49
|
+
|
|
50
|
+
/** Viewport-resize settle window (ms) before the drawing buffer is
|
|
51
|
+
* reallocated — see WallpaperShaderPipeline.onResize. */
|
|
52
|
+
const RESIZE_SETTLE_MS = 140;
|
|
53
|
+
|
|
54
|
+
function compileShader(
|
|
55
|
+
gl: WebGL2RenderingContext,
|
|
56
|
+
type: number,
|
|
57
|
+
source: string,
|
|
58
|
+
): WebGLShader | null {
|
|
59
|
+
const shader = gl.createShader(type);
|
|
60
|
+
if (!shader) return null;
|
|
61
|
+
gl.shaderSource(shader, source);
|
|
62
|
+
gl.compileShader(shader);
|
|
63
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
64
|
+
gl.deleteShader(shader);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return shader;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createProgram(
|
|
71
|
+
gl: WebGL2RenderingContext,
|
|
72
|
+
vertSrc: string,
|
|
73
|
+
fragSrc: string,
|
|
74
|
+
): WebGLProgram | null {
|
|
75
|
+
const vs = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
|
|
76
|
+
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
|
|
77
|
+
if (!vs || !fs) return null;
|
|
78
|
+
const prog = gl.createProgram();
|
|
79
|
+
if (!prog) return null;
|
|
80
|
+
gl.attachShader(prog, vs);
|
|
81
|
+
gl.attachShader(prog, fs);
|
|
82
|
+
gl.linkProgram(prog);
|
|
83
|
+
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
|
84
|
+
gl.deleteProgram(prog);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return prog;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One pipeline lifetime: one canvas attach, one program, one frame loop.
|
|
92
|
+
* Hosts normally reach this through {@link createWallpaperShaderSurface};
|
|
93
|
+
* the class is exported for hosts that need to drive the lifecycle
|
|
94
|
+
* themselves (embedding the canvas outside the backdrop component).
|
|
95
|
+
*/
|
|
96
|
+
export class WallpaperShaderPipeline {
|
|
97
|
+
private gl: WebGL2RenderingContext | null = null;
|
|
98
|
+
private program: WebGLProgram | null = null;
|
|
99
|
+
private vao: WebGLVertexArrayObject | null = null;
|
|
100
|
+
private startTime: number = 0;
|
|
101
|
+
private uTime: WebGLUniformLocation | null = null;
|
|
102
|
+
private uResolution: WebGLUniformLocation | null = null;
|
|
103
|
+
private uScale: WebGLUniformLocation | null = null;
|
|
104
|
+
private uPeriod: WebGLUniformLocation | null = null;
|
|
105
|
+
private uTexture: WebGLUniformLocation | null = null;
|
|
106
|
+
private canvas: HTMLCanvasElement | null = null;
|
|
107
|
+
private currentPreset: string = "";
|
|
108
|
+
private currentPeriod: TimePeriod = "day";
|
|
109
|
+
private handle: AnimationHandle | null = null;
|
|
110
|
+
private resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
private texture: WebGLTexture | null = null;
|
|
112
|
+
private pendingTexImg: HTMLImageElement | null = null;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Attach to a component-owned canvas + live WebGL2 context (the
|
|
116
|
+
* HkWallpaperBackdrop surface contract: the component owns the CSS box
|
|
117
|
+
* and the context, the driver owns the drawing buffer and the pixels).
|
|
118
|
+
* No DOM writes, no canvas removal on failure — the caller's canvas
|
|
119
|
+
* stays exactly where it was. Returns false (fully rolled back) when
|
|
120
|
+
* the preset is unknown or the program cannot build.
|
|
121
|
+
*/
|
|
122
|
+
attach(
|
|
123
|
+
canvas: HTMLCanvasElement,
|
|
124
|
+
gl: WebGL2RenderingContext,
|
|
125
|
+
presetId: string,
|
|
126
|
+
period: TimePeriod,
|
|
127
|
+
): boolean {
|
|
128
|
+
this.destroy();
|
|
129
|
+
|
|
130
|
+
const preset = getShaderPreset(presetId);
|
|
131
|
+
if (!preset) return false;
|
|
132
|
+
|
|
133
|
+
this.currentPreset = presetId;
|
|
134
|
+
this.currentPeriod = period;
|
|
135
|
+
|
|
136
|
+
this.canvas = canvas;
|
|
137
|
+
this.gl = gl;
|
|
138
|
+
gl.clearColor(0, 0, 0, 0);
|
|
139
|
+
|
|
140
|
+
const prog = createProgram(gl, SHADER_VERTEX, preset.fragment);
|
|
141
|
+
if (!prog) {
|
|
142
|
+
this.gl = null;
|
|
143
|
+
this.canvas = null;
|
|
144
|
+
this.currentPreset = "";
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
this.program = prog;
|
|
148
|
+
|
|
149
|
+
gl.useProgram(prog);
|
|
150
|
+
|
|
151
|
+
this.uTime = gl.getUniformLocation(prog, "u_time");
|
|
152
|
+
this.uResolution = gl.getUniformLocation(prog, "u_resolution");
|
|
153
|
+
this.uScale = gl.getUniformLocation(prog, "u_scale");
|
|
154
|
+
this.uPeriod = gl.getUniformLocation(prog, "u_period");
|
|
155
|
+
this.uTexture = gl.getUniformLocation(prog, "u_texture");
|
|
156
|
+
|
|
157
|
+
if (preset.texture) {
|
|
158
|
+
this.loadTexture(preset.texture);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const vao = gl.createVertexArray();
|
|
162
|
+
this.vao = vao;
|
|
163
|
+
gl.bindVertexArray(vao);
|
|
164
|
+
|
|
165
|
+
const buf = gl.createBuffer();
|
|
166
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
167
|
+
gl.bufferData(
|
|
168
|
+
gl.ARRAY_BUFFER,
|
|
169
|
+
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
|
170
|
+
gl.STATIC_DRAW,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const aPos = gl.getAttribLocation(prog, "a_position");
|
|
174
|
+
gl.enableVertexAttribArray(aPos);
|
|
175
|
+
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
|
176
|
+
|
|
177
|
+
this.startTime = performance.now();
|
|
178
|
+
this.resize();
|
|
179
|
+
|
|
180
|
+
this.handle = onFrame((ctx) => this.render(ctx), "normal");
|
|
181
|
+
|
|
182
|
+
window.addEventListener("resize", this.onResize);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
setPeriod(period: TimePeriod) {
|
|
187
|
+
this.currentPeriod = period;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private render(ctx: { now: number }) {
|
|
191
|
+
const gl = this.gl;
|
|
192
|
+
if (!gl || !this.canvas) return;
|
|
193
|
+
|
|
194
|
+
const elapsed = (ctx.now - this.startTime) / 1000;
|
|
195
|
+
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
|
196
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
197
|
+
gl.uniform1f(this.uTime, elapsed);
|
|
198
|
+
gl.uniform2f(this.uResolution, this.canvas.width, this.canvas.height);
|
|
199
|
+
if (this.uScale) gl.uniform1f(this.uScale, this.computeScale());
|
|
200
|
+
|
|
201
|
+
let periodVal = 2.0;
|
|
202
|
+
if (this.currentPeriod === "day") periodVal = 0.0;
|
|
203
|
+
else if (this.currentPeriod === "dusk") periodVal = 1.0;
|
|
204
|
+
gl.uniform1f(this.uPeriod, periodVal);
|
|
205
|
+
|
|
206
|
+
if (this.texture && this.uTexture) {
|
|
207
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
208
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
209
|
+
gl.uniform1i(this.uTexture, 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private computeScale(): number {
|
|
216
|
+
if (!this.canvas) return 1.0;
|
|
217
|
+
const preset = getShaderPreset(this.currentPreset);
|
|
218
|
+
const cfg = preset?.render;
|
|
219
|
+
if (!cfg) return 1.0;
|
|
220
|
+
const aspect = this.canvas.width / this.canvas.height;
|
|
221
|
+
if (aspect < 1.0) {
|
|
222
|
+
return cfg.mobile(aspect);
|
|
223
|
+
}
|
|
224
|
+
return cfg.desktop;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private loadTexture(url: string) {
|
|
228
|
+
const gl = this.gl;
|
|
229
|
+
if (!gl) return;
|
|
230
|
+
|
|
231
|
+
const tex = gl.createTexture();
|
|
232
|
+
if (!tex) return;
|
|
233
|
+
this.texture = tex;
|
|
234
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
235
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 0]));
|
|
236
|
+
|
|
237
|
+
const img = new Image();
|
|
238
|
+
this.pendingTexImg = img;
|
|
239
|
+
img.onload = () => {
|
|
240
|
+
if (!this.gl || !this.texture || this.pendingTexImg !== img) return;
|
|
241
|
+
this.gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
242
|
+
this.gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
243
|
+
this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
244
|
+
this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
245
|
+
this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
246
|
+
this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
247
|
+
this.pendingTexImg = null;
|
|
248
|
+
};
|
|
249
|
+
img.src = url;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private onResize = () => {
|
|
253
|
+
// Settle-debounce: viewport-resize storms (phone keyboard animations
|
|
254
|
+
// step the viewport every frame for ~250ms; window drags/rotations
|
|
255
|
+
// step it too) must not reallocate the drawing buffer per step —
|
|
256
|
+
// each realloc discards the GPU texture and the gap before the next
|
|
257
|
+
// submitted frame renders as a black flash. While a debounce is
|
|
258
|
+
// pending the canvas keeps drawing into the old buffer; CSS
|
|
259
|
+
// (width/height 100%) stretches it, which background art survives
|
|
260
|
+
// fine for the ~140ms settle window.
|
|
261
|
+
if (this.resizeTimer != null) clearTimeout(this.resizeTimer);
|
|
262
|
+
this.resizeTimer = setTimeout(() => {
|
|
263
|
+
this.resizeTimer = null;
|
|
264
|
+
this.resize();
|
|
265
|
+
}, RESIZE_SETTLE_MS);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
private resize() {
|
|
269
|
+
if (!this.canvas || !this.gl) return;
|
|
270
|
+
// Size against the LAYOUT viewport (documentElement client size),
|
|
271
|
+
// not window.innerWidth/innerHeight (visual viewport). Under the
|
|
272
|
+
// default interactive-widget=resizes-visual the soft keyboard
|
|
273
|
+
// shrinks only the visual viewport — this canvas' CSS box (fixed,
|
|
274
|
+
// 100% of the ICB) never moves, so reallocating the buffer on an
|
|
275
|
+
// innerHeight change produced zero visual change while discarding
|
|
276
|
+
// the GPU texture mid-animation (the 2026-09-21 phone IME black
|
|
277
|
+
// flicker report). The layout viewport only moves for real
|
|
278
|
+
// geometry changes (URL-bar collapse, rotation, window resize),
|
|
279
|
+
// which is exactly when the buffer SHOULD follow.
|
|
280
|
+
const vw = document.documentElement.clientWidth || window.innerWidth;
|
|
281
|
+
const vh = document.documentElement.clientHeight || window.innerHeight;
|
|
282
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
283
|
+
const scale = 0.5;
|
|
284
|
+
const w = Math.floor(vw * dpr * scale);
|
|
285
|
+
const h = Math.floor(vh * dpr * scale);
|
|
286
|
+
if (this.canvas.width !== w || this.canvas.height !== h) {
|
|
287
|
+
this.canvas.width = w;
|
|
288
|
+
this.canvas.height = h;
|
|
289
|
+
this.canvas.style.width = "100%";
|
|
290
|
+
this.canvas.style.height = "100%";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
destroy() {
|
|
295
|
+
if (this.handle) {
|
|
296
|
+
this.handle.disconnect();
|
|
297
|
+
this.handle = null;
|
|
298
|
+
}
|
|
299
|
+
if (this.resizeTimer != null) {
|
|
300
|
+
clearTimeout(this.resizeTimer);
|
|
301
|
+
this.resizeTimer = null;
|
|
302
|
+
}
|
|
303
|
+
window.removeEventListener("resize", this.onResize);
|
|
304
|
+
if (this.gl) {
|
|
305
|
+
if (this.vao) this.gl.deleteVertexArray(this.vao);
|
|
306
|
+
if (this.program) this.gl.deleteProgram(this.program);
|
|
307
|
+
if (this.texture) this.gl.deleteTexture(this.texture);
|
|
308
|
+
this.gl = null;
|
|
309
|
+
}
|
|
310
|
+
this.program = null;
|
|
311
|
+
this.vao = null;
|
|
312
|
+
this.uTime = null;
|
|
313
|
+
this.uResolution = null;
|
|
314
|
+
this.uScale = null;
|
|
315
|
+
this.uPeriod = null;
|
|
316
|
+
this.uTexture = null;
|
|
317
|
+
this.texture = null;
|
|
318
|
+
this.pendingTexImg = null;
|
|
319
|
+
// The canvas is component-owned (HkWallpaperBackdrop removes it with
|
|
320
|
+
// its own tree); the driver only forgets it.
|
|
321
|
+
this.canvas = null;
|
|
322
|
+
this.currentPreset = "";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
get isActive(): boolean {
|
|
326
|
+
return this.handle !== null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
get presetId(): string {
|
|
330
|
+
return this.currentPreset;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The ready-made pipeline driver for `HkWallpaperBackdrop`: one
|
|
336
|
+
* {@link WallpaperShaderPipeline} per surface attach, resolved against
|
|
337
|
+
* the shared preset registry ({@link getShaderPreset}). Hand it to the
|
|
338
|
+
* component through the backdrop decor registration's props bag:
|
|
339
|
+
*
|
|
340
|
+
* ```ts
|
|
341
|
+
* registerThemeDecor({
|
|
342
|
+
* themeId: "*", slot: "backdrop", component: HkWallpaperBackdrop,
|
|
343
|
+
* props: { createSurface: createWallpaperShaderSurface },
|
|
344
|
+
* });
|
|
345
|
+
* ```
|
|
346
|
+
*
|
|
347
|
+
* Returning `null` on an unknown preset or a failed program build is the
|
|
348
|
+
* contract's "decline" — the component then stands down to the solid
|
|
349
|
+
* floor instead of leaving an invisible canvas on screen.
|
|
350
|
+
*/
|
|
351
|
+
export const createWallpaperShaderSurface: HkWallpaperSurfaceFactory = (
|
|
352
|
+
ctx: HkWallpaperSurfaceContext,
|
|
353
|
+
): HkWallpaperSurface | null => {
|
|
354
|
+
const pipeline = new WallpaperShaderPipeline();
|
|
355
|
+
if (!pipeline.attach(ctx.canvas, ctx.gl, ctx.presetId, ctx.period)) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
setPeriod: (period) => pipeline.setPeriod(period),
|
|
360
|
+
dispose: () => pipeline.destroy(),
|
|
361
|
+
};
|
|
362
|
+
};
|