@stevejtrettel/shader-sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +391 -0
  2. package/bin/cli.js +389 -0
  3. package/dist-lib/app/App.d.ts +134 -0
  4. package/dist-lib/app/App.d.ts.map +1 -0
  5. package/dist-lib/app/App.js +570 -0
  6. package/dist-lib/app/types.d.ts +32 -0
  7. package/dist-lib/app/types.d.ts.map +1 -0
  8. package/dist-lib/app/types.js +6 -0
  9. package/dist-lib/editor/EditorPanel.d.ts +39 -0
  10. package/dist-lib/editor/EditorPanel.d.ts.map +1 -0
  11. package/dist-lib/editor/EditorPanel.js +274 -0
  12. package/dist-lib/editor/prism-editor.css +99 -0
  13. package/dist-lib/editor/prism-editor.d.ts +19 -0
  14. package/dist-lib/editor/prism-editor.d.ts.map +1 -0
  15. package/dist-lib/editor/prism-editor.js +96 -0
  16. package/dist-lib/embed.d.ts +17 -0
  17. package/dist-lib/embed.d.ts.map +1 -0
  18. package/dist-lib/embed.js +35 -0
  19. package/dist-lib/engine/ShadertoyEngine.d.ts +160 -0
  20. package/dist-lib/engine/ShadertoyEngine.d.ts.map +1 -0
  21. package/dist-lib/engine/ShadertoyEngine.js +704 -0
  22. package/dist-lib/engine/glHelpers.d.ts +79 -0
  23. package/dist-lib/engine/glHelpers.d.ts.map +1 -0
  24. package/dist-lib/engine/glHelpers.js +298 -0
  25. package/dist-lib/engine/types.d.ts +77 -0
  26. package/dist-lib/engine/types.d.ts.map +1 -0
  27. package/dist-lib/engine/types.js +7 -0
  28. package/dist-lib/index.d.ts +12 -0
  29. package/dist-lib/index.d.ts.map +1 -0
  30. package/dist-lib/index.js +9 -0
  31. package/dist-lib/layouts/DefaultLayout.d.ts +17 -0
  32. package/dist-lib/layouts/DefaultLayout.d.ts.map +1 -0
  33. package/dist-lib/layouts/DefaultLayout.js +27 -0
  34. package/dist-lib/layouts/FullscreenLayout.d.ts +17 -0
  35. package/dist-lib/layouts/FullscreenLayout.d.ts.map +1 -0
  36. package/dist-lib/layouts/FullscreenLayout.js +27 -0
  37. package/dist-lib/layouts/SplitLayout.d.ts +26 -0
  38. package/dist-lib/layouts/SplitLayout.d.ts.map +1 -0
  39. package/dist-lib/layouts/SplitLayout.js +61 -0
  40. package/dist-lib/layouts/TabbedLayout.d.ts +38 -0
  41. package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -0
  42. package/dist-lib/layouts/TabbedLayout.js +305 -0
  43. package/dist-lib/layouts/index.d.ts +24 -0
  44. package/dist-lib/layouts/index.d.ts.map +1 -0
  45. package/dist-lib/layouts/index.js +36 -0
  46. package/dist-lib/layouts/split.css +196 -0
  47. package/dist-lib/layouts/tabbed.css +345 -0
  48. package/dist-lib/layouts/types.d.ts +48 -0
  49. package/dist-lib/layouts/types.d.ts.map +1 -0
  50. package/dist-lib/layouts/types.js +4 -0
  51. package/dist-lib/main.d.ts +15 -0
  52. package/dist-lib/main.d.ts.map +1 -0
  53. package/dist-lib/main.js +102 -0
  54. package/dist-lib/project/generatedLoader.d.ts +3 -0
  55. package/dist-lib/project/generatedLoader.d.ts.map +1 -0
  56. package/dist-lib/project/generatedLoader.js +17 -0
  57. package/dist-lib/project/loadProject.d.ts +22 -0
  58. package/dist-lib/project/loadProject.d.ts.map +1 -0
  59. package/dist-lib/project/loadProject.js +350 -0
  60. package/dist-lib/project/loaderHelper.d.ts +7 -0
  61. package/dist-lib/project/loaderHelper.d.ts.map +1 -0
  62. package/dist-lib/project/loaderHelper.js +240 -0
  63. package/dist-lib/project/types.d.ts +192 -0
  64. package/dist-lib/project/types.d.ts.map +1 -0
  65. package/dist-lib/project/types.js +7 -0
  66. package/dist-lib/styles/base.css +29 -0
  67. package/package.json +48 -0
  68. package/src/app/App.ts +699 -0
  69. package/src/app/app.css +208 -0
  70. package/src/app/types.ts +36 -0
  71. package/src/editor/EditorPanel.ts +340 -0
  72. package/src/editor/editor-panel.css +175 -0
  73. package/src/editor/prism-editor.css +99 -0
  74. package/src/editor/prism-editor.ts +124 -0
  75. package/src/embed.ts +55 -0
  76. package/src/engine/ShadertoyEngine.ts +929 -0
  77. package/src/engine/glHelpers.ts +432 -0
  78. package/src/engine/types.ts +118 -0
  79. package/src/index.ts +13 -0
  80. package/src/layouts/DefaultLayout.ts +40 -0
  81. package/src/layouts/FullscreenLayout.ts +40 -0
  82. package/src/layouts/SplitLayout.ts +81 -0
  83. package/src/layouts/TabbedLayout.ts +371 -0
  84. package/src/layouts/default.css +22 -0
  85. package/src/layouts/fullscreen.css +15 -0
  86. package/src/layouts/index.ts +44 -0
  87. package/src/layouts/split.css +196 -0
  88. package/src/layouts/tabbed.css +345 -0
  89. package/src/layouts/types.ts +58 -0
  90. package/src/main.ts +114 -0
  91. package/src/project/generatedLoader.ts +23 -0
  92. package/src/project/loadProject.ts +421 -0
  93. package/src/project/loaderHelper.ts +300 -0
  94. package/src/project/types.ts +243 -0
  95. package/src/styles/base.css +29 -0
  96. package/src/styles/embed.css +14 -0
  97. package/src/vite-env.d.ts +1 -0
  98. package/templates/index.html +28 -0
  99. package/templates/main.ts +126 -0
  100. package/templates/package.json +12 -0
  101. package/templates/shaders/example-buffer/bufferA.glsl +14 -0
  102. package/templates/shaders/example-buffer/config.json +10 -0
  103. package/templates/shaders/example-buffer/image.glsl +5 -0
  104. package/templates/shaders/example-gradient/config.json +4 -0
  105. package/templates/shaders/example-gradient/image.glsl +7 -0
  106. package/templates/vite.config.js +35 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Project Layer - Config Loader
3
+ *
4
+ * Loads Shadertoy projects from disk into normalized ShadertoyProject representation.
5
+ * Handles both single-pass (no config) and multi-pass (with config) projects.
6
+ *
7
+ * Based on docs/project-spec.md
8
+ */
9
+ import { promises as fs } from 'fs';
10
+ import * as path from 'path';
11
+ // =============================================================================
12
+ // Helper Functions
13
+ // =============================================================================
14
+ /**
15
+ * Check if a file exists.
16
+ */
17
+ async function fileExists(p) {
18
+ try {
19
+ await fs.access(p);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /**
27
+ * Type guard for PassName.
28
+ */
29
+ function isPassName(s) {
30
+ return s === 'Image' || s === 'BufferA' || s === 'BufferB' || s === 'BufferC' || s === 'BufferD';
31
+ }
32
+ /**
33
+ * List all .glsl files in a directory.
34
+ */
35
+ async function listGlslFiles(root) {
36
+ const entries = await fs.readdir(root, { withFileTypes: true });
37
+ return entries
38
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.glsl'))
39
+ .map((e) => e.name);
40
+ }
41
+ /**
42
+ * Check if project has a textures/ directory with files.
43
+ */
44
+ async function hasTexturesDirWithFiles(root) {
45
+ const dir = path.join(root, 'textures');
46
+ if (!(await fileExists(dir)))
47
+ return false;
48
+ const entries = await fs.readdir(dir, { withFileTypes: true });
49
+ return entries.some((e) => e.isFile());
50
+ }
51
+ /**
52
+ * Get default source file name for a pass.
53
+ */
54
+ function defaultSourceForPass(name) {
55
+ switch (name) {
56
+ case 'Image':
57
+ return 'image.glsl';
58
+ case 'BufferA':
59
+ return 'bufferA.glsl';
60
+ case 'BufferB':
61
+ return 'bufferB.glsl';
62
+ case 'BufferC':
63
+ return 'bufferC.glsl';
64
+ case 'BufferD':
65
+ return 'bufferD.glsl';
66
+ }
67
+ }
68
+ // =============================================================================
69
+ // Main Entry Point
70
+ // =============================================================================
71
+ /**
72
+ * Load a Shadertoy project from disk.
73
+ *
74
+ * Automatically detects:
75
+ * - Single-pass mode (no config, just image.glsl)
76
+ * - Multi-pass mode (config.json present)
77
+ *
78
+ * @param root - Absolute path to project directory
79
+ * @returns Fully normalized ShadertoyProject
80
+ * @throws Error with descriptive message if project is invalid
81
+ */
82
+ export async function loadProject(root) {
83
+ const configPath = path.join(root, 'config.json');
84
+ const hasConfig = await fileExists(configPath);
85
+ if (hasConfig) {
86
+ // Multi-pass mode: parse config
87
+ const raw = await fs.readFile(configPath, 'utf8');
88
+ let config;
89
+ try {
90
+ config = JSON.parse(raw);
91
+ }
92
+ catch (err) {
93
+ throw new Error(`Invalid JSON in config.json at '${root}': ${err?.message ?? String(err)}`);
94
+ }
95
+ return await loadProjectWithConfig(root, config);
96
+ }
97
+ else {
98
+ // Single-pass mode: just image.glsl
99
+ return await loadSinglePassProject(root);
100
+ }
101
+ }
102
+ // =============================================================================
103
+ // Single-Pass Mode (No Config)
104
+ // =============================================================================
105
+ /**
106
+ * Load a simple single-pass project.
107
+ *
108
+ * Requirements:
109
+ * - Must have image.glsl
110
+ * - Cannot have other .glsl files
111
+ * - Cannot have textures/ directory
112
+ * - No common.glsl allowed
113
+ *
114
+ * @param root - Project directory
115
+ * @returns ShadertoyProject with only Image pass
116
+ */
117
+ async function loadSinglePassProject(root) {
118
+ const imagePath = path.join(root, 'image.glsl');
119
+ if (!(await fileExists(imagePath))) {
120
+ throw new Error(`Single-pass project at '${root}' requires 'image.glsl'.`);
121
+ }
122
+ // Check for extra GLSL files
123
+ const glslFiles = await listGlslFiles(root);
124
+ const extraGlsl = glslFiles.filter((name) => name !== 'image.glsl');
125
+ if (extraGlsl.length > 0) {
126
+ throw new Error(`Project at '${root}' contains multiple GLSL files (${glslFiles.join(', ')}) but no 'config.json'. Add a config file to use multiple passes.`);
127
+ }
128
+ // Check for textures
129
+ if (await hasTexturesDirWithFiles(root)) {
130
+ throw new Error(`Project at '${root}' uses textures (in 'textures/' folder) but has no 'config.json'. Add a config file to define texture bindings.`);
131
+ }
132
+ // Load shader source
133
+ const imageSource = await fs.readFile(imagePath, 'utf8');
134
+ const title = path.basename(root);
135
+ const project = {
136
+ root,
137
+ meta: {
138
+ title,
139
+ author: null,
140
+ description: null,
141
+ },
142
+ layout: 'default',
143
+ controls: false,
144
+ commonSource: null,
145
+ passes: {
146
+ Image: {
147
+ name: 'Image',
148
+ glslSource: imageSource,
149
+ channels: [
150
+ { kind: 'none' },
151
+ { kind: 'none' },
152
+ { kind: 'none' },
153
+ { kind: 'none' },
154
+ ],
155
+ },
156
+ },
157
+ textures: [],
158
+ };
159
+ return project;
160
+ }
161
+ // =============================================================================
162
+ // Multi-Pass Mode (With Config)
163
+ // =============================================================================
164
+ /**
165
+ * Parse a channel value (string shorthand or object) into normalized ChannelJSONObject.
166
+ *
167
+ * String shortcuts:
168
+ * - "BufferA", "BufferB", etc. → buffer reference
169
+ * - "keyboard" → keyboard input
170
+ * - "photo.jpg" (with extension) → texture file
171
+ */
172
+ function parseChannelValue(value) {
173
+ if (typeof value === 'string') {
174
+ // Check for buffer names
175
+ if (isPassName(value)) {
176
+ return { buffer: value };
177
+ }
178
+ // Check for keyboard
179
+ if (value === 'keyboard') {
180
+ return { keyboard: true };
181
+ }
182
+ // Assume texture (file path)
183
+ return { texture: value };
184
+ }
185
+ // Already an object
186
+ return value;
187
+ }
188
+ /**
189
+ * Load a project with config.json.
190
+ *
191
+ * @param root - Project directory
192
+ * @param config - Parsed JSON config
193
+ * @returns Normalized ShadertoyProject
194
+ */
195
+ async function loadProjectWithConfig(root, config) {
196
+ // Extract pass configs from top level
197
+ const passConfigs = {
198
+ Image: config.Image,
199
+ BufferA: config.BufferA,
200
+ BufferB: config.BufferB,
201
+ BufferC: config.BufferC,
202
+ BufferD: config.BufferD,
203
+ };
204
+ // Validate: must have Image pass (or be empty config for simple shader)
205
+ const hasAnyPass = passConfigs.Image || passConfigs.BufferA || passConfigs.BufferB ||
206
+ passConfigs.BufferC || passConfigs.BufferD;
207
+ if (!hasAnyPass) {
208
+ // Empty config = simple Image pass with no channels
209
+ passConfigs.Image = {};
210
+ }
211
+ // Resolve commonSource
212
+ let commonSource = null;
213
+ if (config.common) {
214
+ const commonPath = path.join(root, config.common);
215
+ if (!(await fileExists(commonPath))) {
216
+ throw new Error(`Common GLSL file '${config.common}' not found in '${root}'.`);
217
+ }
218
+ commonSource = await fs.readFile(commonPath, 'utf8');
219
+ }
220
+ else {
221
+ // Check for default common.glsl
222
+ const defaultCommonPath = path.join(root, 'common.glsl');
223
+ if (await fileExists(defaultCommonPath)) {
224
+ commonSource = await fs.readFile(defaultCommonPath, 'utf8');
225
+ }
226
+ }
227
+ // Texture deduplication map
228
+ const textureMap = new Map();
229
+ /**
230
+ * Register a texture and return its internal name.
231
+ */
232
+ function registerTexture(j) {
233
+ const filter = j.filter ?? 'linear';
234
+ const wrap = j.wrap ?? 'repeat';
235
+ const key = `${j.texture}|${filter}|${wrap}`;
236
+ let existing = textureMap.get(key);
237
+ if (existing) {
238
+ return existing.name;
239
+ }
240
+ const name = `tex${textureMap.size}`;
241
+ const tex = {
242
+ name,
243
+ source: j.texture,
244
+ filter,
245
+ wrap,
246
+ };
247
+ textureMap.set(key, tex);
248
+ return name;
249
+ }
250
+ /**
251
+ * Parse a channel object into ChannelSource.
252
+ */
253
+ function parseChannelObject(value, passName, channelKey) {
254
+ // Buffer channel
255
+ if ('buffer' in value) {
256
+ const buf = value.buffer;
257
+ if (!isPassName(buf)) {
258
+ throw new Error(`Invalid buffer name '${buf}' for ${channelKey} in pass '${passName}' at '${root}'.`);
259
+ }
260
+ return {
261
+ kind: 'buffer',
262
+ buffer: buf,
263
+ current: !!value.current,
264
+ };
265
+ }
266
+ // Texture channel
267
+ if ('texture' in value) {
268
+ const internalName = registerTexture(value);
269
+ return {
270
+ kind: 'texture',
271
+ name: internalName,
272
+ cubemap: value.type === 'cubemap',
273
+ };
274
+ }
275
+ // Keyboard channel
276
+ if ('keyboard' in value) {
277
+ return { kind: 'keyboard' };
278
+ }
279
+ throw new Error(`Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`);
280
+ }
281
+ /**
282
+ * Load a single pass from simplified config.
283
+ */
284
+ async function loadPass(name, passConfig) {
285
+ if (!passConfig)
286
+ return undefined;
287
+ const sourceRel = passConfig.source ?? defaultSourceForPass(name);
288
+ const sourcePath = path.join(root, sourceRel);
289
+ if (!(await fileExists(sourcePath))) {
290
+ throw new Error(`Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`);
291
+ }
292
+ const glslSource = await fs.readFile(sourcePath, 'utf8');
293
+ // Normalize channels (always 4 channels)
294
+ const channelSources = [];
295
+ const channelKeys = ['iChannel0', 'iChannel1', 'iChannel2', 'iChannel3'];
296
+ for (const key of channelKeys) {
297
+ const rawValue = passConfig[key];
298
+ if (!rawValue) {
299
+ channelSources.push({ kind: 'none' });
300
+ continue;
301
+ }
302
+ // Parse string shorthand or use object directly
303
+ const parsed = parseChannelValue(rawValue);
304
+ if (!parsed) {
305
+ channelSources.push({ kind: 'none' });
306
+ continue;
307
+ }
308
+ channelSources.push(parseChannelObject(parsed, name, key));
309
+ }
310
+ return {
311
+ name,
312
+ glslSource,
313
+ channels: channelSources,
314
+ };
315
+ }
316
+ // Load all passes
317
+ const imagePass = await loadPass('Image', passConfigs.Image);
318
+ const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
319
+ const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
320
+ const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
321
+ const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
322
+ // If no Image pass was loaded but we have buffers, that's an error
323
+ if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
324
+ throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
325
+ }
326
+ // If still no Image pass, create empty one
327
+ if (!imagePass) {
328
+ throw new Error(`config.json at '${root}' must define an Image pass.`);
329
+ }
330
+ // Build metadata
331
+ const title = config.title ?? path.basename(root);
332
+ const author = config.author ?? null;
333
+ const description = config.description ?? null;
334
+ const project = {
335
+ root,
336
+ meta: { title, author, description },
337
+ layout: config.layout ?? 'default',
338
+ controls: config.controls ?? false,
339
+ commonSource,
340
+ passes: {
341
+ Image: imagePass,
342
+ BufferA: bufferAPass,
343
+ BufferB: bufferBPass,
344
+ BufferC: bufferCPass,
345
+ BufferD: bufferDPass,
346
+ },
347
+ textures: Array.from(textureMap.values()),
348
+ };
349
+ return project;
350
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Helper functions for loading demo files
3
+ * Called by the generated loader
4
+ */
5
+ import { ShadertoyProject, ShadertoyConfig } from './types';
6
+ export declare function loadDemo(demoName: string, glslFiles: Record<string, () => Promise<string>>, jsonFiles: Record<string, () => Promise<ShadertoyConfig>>, imageFiles: Record<string, () => Promise<string>>): Promise<ShadertoyProject>;
7
+ //# sourceMappingURL=loaderHelper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loaderHelper.d.ts","sourceRoot":"","sources":["../../src/project/loaderHelper.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EACL,gBAAgB,EAChB,eAAe,EAIhB,MAAM,SAAS,CAAC;AA8CjB,wBAAsB,QAAQ,CAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,EAChD,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,eAAe,CAAC,CAAC,EACzD,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,GAChD,OAAO,CAAC,gBAAgB,CAAC,CAkB3B"}
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Helper functions for loading demo files
3
+ * Called by the generated loader
4
+ */
5
+ /**
6
+ * Case-insensitive file lookup helper.
7
+ * Returns the actual key from the record that matches the path (case-insensitive).
8
+ */
9
+ function findFileCaseInsensitive(files, path) {
10
+ // First try exact match
11
+ if (path in files)
12
+ return path;
13
+ // Try case-insensitive match
14
+ const lowerPath = path.toLowerCase();
15
+ for (const key of Object.keys(files)) {
16
+ if (key.toLowerCase() === lowerPath) {
17
+ return key;
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ /**
23
+ * Type guard for PassName.
24
+ */
25
+ function isPassName(s) {
26
+ return s === 'Image' || s === 'BufferA' || s === 'BufferB' || s === 'BufferC' || s === 'BufferD';
27
+ }
28
+ /**
29
+ * Parse a channel value (string shorthand or object) into normalized ChannelJSONObject.
30
+ */
31
+ function parseChannelValue(value) {
32
+ if (typeof value === 'string') {
33
+ if (isPassName(value)) {
34
+ return { buffer: value };
35
+ }
36
+ if (value === 'keyboard') {
37
+ return { keyboard: true };
38
+ }
39
+ return { texture: value };
40
+ }
41
+ return value;
42
+ }
43
+ export async function loadDemo(demoName, glslFiles, jsonFiles, imageFiles) {
44
+ const configPath = `/demos/${demoName}/config.json`;
45
+ const hasConfig = configPath in jsonFiles;
46
+ if (hasConfig) {
47
+ const config = await jsonFiles[configPath]();
48
+ const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
49
+ config.BufferC || config.BufferD;
50
+ if (hasPassConfigs) {
51
+ return loadWithConfig(demoName, config, glslFiles, imageFiles);
52
+ }
53
+ else {
54
+ // Config with only settings (layout, controls, etc.) but no passes
55
+ return loadSinglePass(demoName, glslFiles, config);
56
+ }
57
+ }
58
+ else {
59
+ return loadSinglePass(demoName, glslFiles);
60
+ }
61
+ }
62
+ async function loadSinglePass(demoName, glslFiles, configOverrides) {
63
+ const imagePath = `/demos/${demoName}/image.glsl`;
64
+ const actualImagePath = findFileCaseInsensitive(glslFiles, imagePath);
65
+ if (!actualImagePath) {
66
+ throw new Error(`Demo '${demoName}' not found. Expected ${imagePath}`);
67
+ }
68
+ const imageSource = await glslFiles[actualImagePath]();
69
+ const layout = configOverrides?.layout || 'tabbed';
70
+ const controls = configOverrides?.controls ?? true;
71
+ const title = configOverrides?.title ||
72
+ demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
73
+ return {
74
+ root: `/demos/${demoName}`,
75
+ meta: {
76
+ title,
77
+ author: configOverrides?.author || null,
78
+ description: configOverrides?.description || null,
79
+ },
80
+ layout,
81
+ controls,
82
+ commonSource: null,
83
+ passes: {
84
+ Image: {
85
+ name: 'Image',
86
+ glslSource: imageSource,
87
+ channels: [
88
+ { kind: 'none' },
89
+ { kind: 'none' },
90
+ { kind: 'none' },
91
+ { kind: 'none' },
92
+ ],
93
+ },
94
+ },
95
+ textures: [],
96
+ };
97
+ }
98
+ async function loadWithConfig(demoName, config, glslFiles, imageFiles) {
99
+ // Extract pass configs from top level
100
+ const passConfigs = {
101
+ Image: config.Image,
102
+ BufferA: config.BufferA,
103
+ BufferB: config.BufferB,
104
+ BufferC: config.BufferC,
105
+ BufferD: config.BufferD,
106
+ };
107
+ // Load common source
108
+ let commonSource = null;
109
+ if (config.common) {
110
+ const commonPath = `/demos/${demoName}/${config.common}`;
111
+ const actualCommonPath = findFileCaseInsensitive(glslFiles, commonPath);
112
+ if (actualCommonPath) {
113
+ commonSource = await glslFiles[actualCommonPath]();
114
+ }
115
+ }
116
+ else {
117
+ const defaultCommonPath = `/demos/${demoName}/common.glsl`;
118
+ const actualCommonPath = findFileCaseInsensitive(glslFiles, defaultCommonPath);
119
+ if (actualCommonPath) {
120
+ commonSource = await glslFiles[actualCommonPath]();
121
+ }
122
+ }
123
+ // Collect all texture paths
124
+ const texturePathsSet = new Set();
125
+ const passOrder = ['Image', 'BufferA', 'BufferB', 'BufferC', 'BufferD'];
126
+ for (const passName of passOrder) {
127
+ const passConfig = passConfigs[passName];
128
+ if (!passConfig)
129
+ continue;
130
+ for (const channelKey of ['iChannel0', 'iChannel1', 'iChannel2', 'iChannel3']) {
131
+ const channelValue = passConfig[channelKey];
132
+ if (!channelValue)
133
+ continue;
134
+ const parsed = parseChannelValue(channelValue);
135
+ if (parsed && 'texture' in parsed) {
136
+ texturePathsSet.add(parsed.texture);
137
+ }
138
+ }
139
+ }
140
+ // Load textures
141
+ const textures = [];
142
+ const texturePathToName = new Map();
143
+ for (const texturePath of texturePathsSet) {
144
+ const fullPath = `/demos/${demoName}/${texturePath.replace(/^\.\//, '')}`;
145
+ const actualPath = findFileCaseInsensitive(imageFiles, fullPath);
146
+ if (!actualPath) {
147
+ throw new Error(`Texture not found: ${texturePath} (expected at ${fullPath})`);
148
+ }
149
+ const imageUrl = await imageFiles[actualPath]();
150
+ const textureFilename = texturePath.split('/').pop();
151
+ const textureName = textureFilename.replace(/\.[^.]+$/, '');
152
+ textures.push({
153
+ name: textureName,
154
+ filename: textureFilename, // Preserve original filename for display
155
+ source: imageUrl,
156
+ filter: 'linear',
157
+ wrap: 'repeat',
158
+ });
159
+ texturePathToName.set(texturePath, textureName);
160
+ }
161
+ // Build passes
162
+ const passes = {};
163
+ for (const passName of passOrder) {
164
+ const passConfig = passConfigs[passName];
165
+ if (!passConfig)
166
+ continue;
167
+ const defaultNames = {
168
+ Image: 'image.glsl',
169
+ BufferA: 'bufferA.glsl',
170
+ BufferB: 'bufferB.glsl',
171
+ BufferC: 'bufferC.glsl',
172
+ BufferD: 'bufferD.glsl',
173
+ };
174
+ const sourceFile = passConfig.source || defaultNames[passName];
175
+ const sourcePath = `/demos/${demoName}/${sourceFile}`;
176
+ const actualSourcePath = findFileCaseInsensitive(glslFiles, sourcePath);
177
+ if (!actualSourcePath) {
178
+ throw new Error(`Missing shader file: ${sourcePath}`);
179
+ }
180
+ const glslSource = await glslFiles[actualSourcePath]();
181
+ const channels = [
182
+ normalizeChannel(passConfig.iChannel0, texturePathToName),
183
+ normalizeChannel(passConfig.iChannel1, texturePathToName),
184
+ normalizeChannel(passConfig.iChannel2, texturePathToName),
185
+ normalizeChannel(passConfig.iChannel3, texturePathToName),
186
+ ];
187
+ passes[passName] = {
188
+ name: passName,
189
+ glslSource,
190
+ channels,
191
+ };
192
+ }
193
+ if (!passes.Image) {
194
+ throw new Error(`Demo '${demoName}' must have an Image pass`);
195
+ }
196
+ const title = config.title ||
197
+ demoName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
198
+ const author = config.author || null;
199
+ const description = config.description || null;
200
+ const layout = config.layout || 'tabbed';
201
+ const controls = config.controls ?? true;
202
+ return {
203
+ root: `/demos/${demoName}`,
204
+ meta: { title, author, description },
205
+ layout,
206
+ controls,
207
+ commonSource,
208
+ passes,
209
+ textures,
210
+ };
211
+ }
212
+ function normalizeChannel(channelValue, texturePathToName) {
213
+ if (!channelValue) {
214
+ return { kind: 'none' };
215
+ }
216
+ // Parse string shorthand
217
+ const parsed = parseChannelValue(channelValue);
218
+ if (!parsed) {
219
+ return { kind: 'none' };
220
+ }
221
+ if ('buffer' in parsed) {
222
+ return {
223
+ kind: 'buffer',
224
+ buffer: parsed.buffer,
225
+ current: !!parsed.current,
226
+ };
227
+ }
228
+ if ('texture' in parsed) {
229
+ const textureName = texturePathToName?.get(parsed.texture) || parsed.texture;
230
+ return {
231
+ kind: 'texture',
232
+ name: textureName,
233
+ cubemap: parsed.type === 'cubemap',
234
+ };
235
+ }
236
+ if ('keyboard' in parsed) {
237
+ return { kind: 'keyboard' };
238
+ }
239
+ return { kind: 'none' };
240
+ }