@stevejtrettel/shader-sandbox 0.1.3 → 0.1.4

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 (113) hide show
  1. package/README.md +220 -23
  2. package/bin/cli.js +106 -14
  3. package/dist-lib/app/App.d.ts +143 -15
  4. package/dist-lib/app/App.d.ts.map +1 -1
  5. package/dist-lib/app/App.js +1343 -108
  6. package/dist-lib/app/app.css +349 -24
  7. package/dist-lib/app/types.d.ts +48 -5
  8. package/dist-lib/app/types.d.ts.map +1 -1
  9. package/dist-lib/editor/EditorPanel.d.ts +2 -2
  10. package/dist-lib/editor/EditorPanel.d.ts.map +1 -1
  11. package/dist-lib/editor/EditorPanel.js +1 -1
  12. package/dist-lib/editor/editor-panel.css +55 -32
  13. package/dist-lib/editor/prism-editor.css +16 -16
  14. package/dist-lib/embed.js +1 -1
  15. package/dist-lib/engine/{ShadertoyEngine.d.ts → ShaderEngine.d.ts} +134 -10
  16. package/dist-lib/engine/ShaderEngine.d.ts.map +1 -0
  17. package/dist-lib/engine/ShaderEngine.js +1523 -0
  18. package/dist-lib/engine/glHelpers.d.ts +24 -0
  19. package/dist-lib/engine/glHelpers.d.ts.map +1 -1
  20. package/dist-lib/engine/glHelpers.js +88 -0
  21. package/dist-lib/engine/std140.d.ts +47 -0
  22. package/dist-lib/engine/std140.d.ts.map +1 -0
  23. package/dist-lib/engine/std140.js +119 -0
  24. package/dist-lib/engine/types.d.ts +55 -5
  25. package/dist-lib/engine/types.d.ts.map +1 -1
  26. package/dist-lib/engine/types.js +1 -1
  27. package/dist-lib/index.d.ts +4 -3
  28. package/dist-lib/index.d.ts.map +1 -1
  29. package/dist-lib/index.js +2 -1
  30. package/dist-lib/layouts/SplitLayout.d.ts +2 -1
  31. package/dist-lib/layouts/SplitLayout.d.ts.map +1 -1
  32. package/dist-lib/layouts/SplitLayout.js +3 -0
  33. package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -1
  34. package/dist-lib/layouts/UILayout.d.ts +55 -0
  35. package/dist-lib/layouts/UILayout.d.ts.map +1 -0
  36. package/dist-lib/layouts/UILayout.js +147 -0
  37. package/dist-lib/layouts/default.css +2 -2
  38. package/dist-lib/layouts/index.d.ts +11 -1
  39. package/dist-lib/layouts/index.d.ts.map +1 -1
  40. package/dist-lib/layouts/index.js +17 -1
  41. package/dist-lib/layouts/split.css +33 -31
  42. package/dist-lib/layouts/tabbed.css +127 -74
  43. package/dist-lib/layouts/types.d.ts +14 -3
  44. package/dist-lib/layouts/types.d.ts.map +1 -1
  45. package/dist-lib/main.js +33 -0
  46. package/dist-lib/project/configHelpers.d.ts +45 -0
  47. package/dist-lib/project/configHelpers.d.ts.map +1 -0
  48. package/dist-lib/project/configHelpers.js +196 -0
  49. package/dist-lib/project/generatedLoader.d.ts +2 -2
  50. package/dist-lib/project/generatedLoader.d.ts.map +1 -1
  51. package/dist-lib/project/generatedLoader.js +23 -5
  52. package/dist-lib/project/loadProject.d.ts +6 -6
  53. package/dist-lib/project/loadProject.d.ts.map +1 -1
  54. package/dist-lib/project/loadProject.js +396 -144
  55. package/dist-lib/project/loaderHelper.d.ts +4 -4
  56. package/dist-lib/project/loaderHelper.d.ts.map +1 -1
  57. package/dist-lib/project/loaderHelper.js +278 -116
  58. package/dist-lib/project/types.d.ts +292 -13
  59. package/dist-lib/project/types.d.ts.map +1 -1
  60. package/dist-lib/project/types.js +13 -1
  61. package/dist-lib/styles/base.css +5 -1
  62. package/dist-lib/uniforms/UniformControls.d.ts +60 -0
  63. package/dist-lib/uniforms/UniformControls.d.ts.map +1 -0
  64. package/dist-lib/uniforms/UniformControls.js +518 -0
  65. package/dist-lib/uniforms/UniformStore.d.ts +74 -0
  66. package/dist-lib/uniforms/UniformStore.d.ts.map +1 -0
  67. package/dist-lib/uniforms/UniformStore.js +145 -0
  68. package/dist-lib/uniforms/UniformsPanel.d.ts +53 -0
  69. package/dist-lib/uniforms/UniformsPanel.d.ts.map +1 -0
  70. package/dist-lib/uniforms/UniformsPanel.js +124 -0
  71. package/dist-lib/uniforms/index.d.ts +11 -0
  72. package/dist-lib/uniforms/index.d.ts.map +1 -0
  73. package/dist-lib/uniforms/index.js +8 -0
  74. package/package.json +1 -1
  75. package/src/app/App.ts +1469 -126
  76. package/src/app/app.css +349 -24
  77. package/src/app/types.ts +53 -5
  78. package/src/editor/EditorPanel.ts +5 -5
  79. package/src/editor/editor-panel.css +55 -32
  80. package/src/editor/prism-editor.css +16 -16
  81. package/src/embed.ts +1 -1
  82. package/src/engine/ShaderEngine.ts +1934 -0
  83. package/src/engine/glHelpers.ts +117 -0
  84. package/src/engine/std140.ts +136 -0
  85. package/src/engine/types.ts +69 -5
  86. package/src/index.ts +4 -3
  87. package/src/layouts/SplitLayout.ts +8 -3
  88. package/src/layouts/TabbedLayout.ts +3 -3
  89. package/src/layouts/UILayout.ts +185 -0
  90. package/src/layouts/default.css +2 -2
  91. package/src/layouts/index.ts +20 -1
  92. package/src/layouts/split.css +33 -31
  93. package/src/layouts/tabbed.css +127 -74
  94. package/src/layouts/types.ts +19 -3
  95. package/src/layouts/ui.css +289 -0
  96. package/src/main.ts +39 -1
  97. package/src/project/configHelpers.ts +225 -0
  98. package/src/project/generatedLoader.ts +27 -6
  99. package/src/project/loadProject.ts +459 -173
  100. package/src/project/loaderHelper.ts +377 -130
  101. package/src/project/types.ts +360 -14
  102. package/src/styles/base.css +5 -1
  103. package/src/styles/theme.css +292 -0
  104. package/src/uniforms/UniformControls.ts +660 -0
  105. package/src/uniforms/UniformStore.ts +166 -0
  106. package/src/uniforms/UniformsPanel.ts +163 -0
  107. package/src/uniforms/index.ts +13 -0
  108. package/src/uniforms/uniform-controls.css +342 -0
  109. package/src/uniforms/uniforms-panel.css +277 -0
  110. package/templates/shaders/example-buffer/config.json +1 -0
  111. package/dist-lib/engine/ShadertoyEngine.d.ts.map +0 -1
  112. package/dist-lib/engine/ShadertoyEngine.js +0 -704
  113. package/src/engine/ShadertoyEngine.ts +0 -929
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Project Layer - Config Loader
2
+ * Project Layer - Config Loader (Node/CLI)
3
3
  *
4
- * Loads Shadertoy projects from disk into normalized ShadertoyProject representation.
4
+ * Loads shader projects from disk into normalized ShaderProject representation.
5
5
  * Handles both single-pass (no config) and multi-pass (with config) projects.
6
6
  *
7
7
  * Based on docs/project-spec.md
@@ -13,14 +13,28 @@ import {
13
13
  PassName,
14
14
  ChannelSource,
15
15
  Channels,
16
- ChannelValue,
17
16
  ChannelJSONObject,
18
17
  ShadertoyConfig,
19
- ShadertoyPass,
20
- ShadertoyProject,
21
- ShadertoyTexture2D,
18
+ StandardConfig,
19
+ StandardBufferConfig,
20
+ ShaderPass,
21
+ ShaderProject,
22
+ ShaderTexture2D,
22
23
  PassConfigSimplified,
24
+ UniformDefinitions,
25
+ isArrayUniform,
23
26
  } from './types';
27
+ import {
28
+ isPassName,
29
+ parseChannelValue,
30
+ defaultSourceForPass,
31
+ validateConfig,
32
+ CHANNEL_KEYS,
33
+ BUFFER_PASS_NAMES,
34
+ DEFAULT_LAYOUT,
35
+ DEFAULT_CONTROLS,
36
+ DEFAULT_THEME,
37
+ } from './configHelpers';
24
38
 
25
39
  // =============================================================================
26
40
  // Helper Functions
@@ -38,13 +52,6 @@ async function fileExists(p: string): Promise<boolean> {
38
52
  }
39
53
  }
40
54
 
41
- /**
42
- * Type guard for PassName.
43
- */
44
- function isPassName(s: string): s is PassName {
45
- return s === 'Image' || s === 'BufferA' || s === 'BufferB' || s === 'BufferC' || s === 'BufferD';
46
- }
47
-
48
55
  /**
49
56
  * List all .glsl files in a directory.
50
57
  */
@@ -66,21 +73,21 @@ async function hasTexturesDirWithFiles(root: string): Promise<boolean> {
66
73
  }
67
74
 
68
75
  /**
69
- * Get default source file name for a pass.
76
+ * Resolve common source from config or default common.glsl.
70
77
  */
71
- function defaultSourceForPass(name: PassName): string {
72
- switch (name) {
73
- case 'Image':
74
- return 'image.glsl';
75
- case 'BufferA':
76
- return 'bufferA.glsl';
77
- case 'BufferB':
78
- return 'bufferB.glsl';
79
- case 'BufferC':
80
- return 'bufferC.glsl';
81
- case 'BufferD':
82
- return 'bufferD.glsl';
78
+ async function resolveCommonSource(root: string, commonField?: string): Promise<string | null> {
79
+ if (commonField) {
80
+ const commonPath = path.join(root, commonField);
81
+ if (!(await fileExists(commonPath))) {
82
+ throw new Error(`Common GLSL file '${commonField}' not found in '${root}'.`);
83
+ }
84
+ return await fs.readFile(commonPath, 'utf8');
83
85
  }
86
+ const defaultCommonPath = path.join(root, 'common.glsl');
87
+ if (await fileExists(defaultCommonPath)) {
88
+ return await fs.readFile(defaultCommonPath, 'utf8');
89
+ }
90
+ return null;
84
91
  }
85
92
 
86
93
  // =============================================================================
@@ -88,24 +95,23 @@ function defaultSourceForPass(name: PassName): string {
88
95
  // =============================================================================
89
96
 
90
97
  /**
91
- * Load a Shadertoy project from disk.
98
+ * Load a shader project from disk.
92
99
  *
93
100
  * Automatically detects:
94
101
  * - Single-pass mode (no config, just image.glsl)
95
102
  * - Multi-pass mode (config.json present)
96
103
  *
97
104
  * @param root - Absolute path to project directory
98
- * @returns Fully normalized ShadertoyProject
105
+ * @returns Fully normalized ShaderProject
99
106
  * @throws Error with descriptive message if project is invalid
100
107
  */
101
- export async function loadProject(root: string): Promise<ShadertoyProject> {
108
+ export async function loadProject(root: string): Promise<ShaderProject> {
102
109
  const configPath = path.join(root, 'config.json');
103
110
  const hasConfig = await fileExists(configPath);
104
111
 
105
112
  if (hasConfig) {
106
- // Multi-pass mode: parse config
107
113
  const raw = await fs.readFile(configPath, 'utf8');
108
- let config: ShadertoyConfig;
114
+ let config: any;
109
115
  try {
110
116
  config = JSON.parse(raw);
111
117
  } catch (err: any) {
@@ -113,9 +119,13 @@ export async function loadProject(root: string): Promise<ShadertoyProject> {
113
119
  `Invalid JSON in config.json at '${root}': ${err?.message ?? String(err)}`
114
120
  );
115
121
  }
116
- return await loadProjectWithConfig(root, config);
122
+ validateConfig(config, root);
123
+
124
+ if (config.mode === 'shadertoy') {
125
+ return await loadShadertoyProject(root, config as ShadertoyConfig);
126
+ }
127
+ return await loadStandardProject(root, config as StandardConfig);
117
128
  } else {
118
- // Single-pass mode: just image.glsl
119
129
  return await loadSinglePassProject(root);
120
130
  }
121
131
  }
@@ -132,17 +142,13 @@ export async function loadProject(root: string): Promise<ShadertoyProject> {
132
142
  * - Cannot have other .glsl files
133
143
  * - Cannot have textures/ directory
134
144
  * - No common.glsl allowed
135
- *
136
- * @param root - Project directory
137
- * @returns ShadertoyProject with only Image pass
138
145
  */
139
- async function loadSinglePassProject(root: string): Promise<ShadertoyProject> {
146
+ async function loadSinglePassProject(root: string): Promise<ShaderProject> {
140
147
  const imagePath = path.join(root, 'image.glsl');
141
148
  if (!(await fileExists(imagePath))) {
142
149
  throw new Error(`Single-pass project at '${root}' requires 'image.glsl'.`);
143
150
  }
144
151
 
145
- // Check for extra GLSL files
146
152
  const glslFiles = await listGlslFiles(root);
147
153
  const extraGlsl = glslFiles.filter((name) => name !== 'image.glsl');
148
154
  if (extraGlsl.length > 0) {
@@ -153,26 +159,24 @@ async function loadSinglePassProject(root: string): Promise<ShadertoyProject> {
153
159
  );
154
160
  }
155
161
 
156
- // Check for textures
157
162
  if (await hasTexturesDirWithFiles(root)) {
158
163
  throw new Error(
159
164
  `Project at '${root}' uses textures (in 'textures/' folder) but has no 'config.json'. Add a config file to define texture bindings.`
160
165
  );
161
166
  }
162
167
 
163
- // Load shader source
164
168
  const imageSource = await fs.readFile(imagePath, 'utf8');
165
169
  const title = path.basename(root);
166
170
 
167
- const project: ShadertoyProject = {
171
+ return {
172
+ mode: 'standard',
168
173
  root,
169
- meta: {
170
- title,
171
- author: null,
172
- description: null,
173
- },
174
- layout: 'default',
175
- controls: false,
174
+ meta: { title, author: null, description: null },
175
+ layout: DEFAULT_LAYOUT,
176
+ theme: DEFAULT_THEME,
177
+ controls: DEFAULT_CONTROLS,
178
+ startPaused: false,
179
+ pixelRatio: null,
176
180
  commonSource: null,
177
181
  passes: {
178
182
  Image: {
@@ -187,49 +191,96 @@ async function loadSinglePassProject(root: string): Promise<ShadertoyProject> {
187
191
  },
188
192
  },
189
193
  textures: [],
194
+ uniforms: {},
195
+ script: null,
190
196
  };
191
-
192
- return project;
193
197
  }
194
198
 
195
199
  // =============================================================================
196
- // Multi-Pass Mode (With Config)
200
+ // Uniform Validation
197
201
  // =============================================================================
198
202
 
199
- /**
200
- * Parse a channel value (string shorthand or object) into normalized ChannelJSONObject.
201
- *
202
- * String shortcuts:
203
- * - "BufferA", "BufferB", etc. → buffer reference
204
- * - "keyboard" → keyboard input
205
- * - "photo.jpg" (with extension) texture file
206
- */
207
- function parseChannelValue(value: ChannelValue): ChannelJSONObject | null {
208
- if (typeof value === 'string') {
209
- // Check for buffer names
210
- if (isPassName(value)) {
211
- return { buffer: value };
203
+ const SCALAR_TYPES = new Set(['float', 'int', 'bool', 'vec2', 'vec3', 'vec4']);
204
+ const ARRAY_TYPES = new Set(['float', 'vec2', 'vec3', 'vec4', 'mat3', 'mat4']);
205
+ const COMPONENT_COUNTS: Record<string, number> = {
206
+ vec2: 2, vec3: 3, vec4: 4,
207
+ };
208
+
209
+ function validateUniforms(uniforms: UniformDefinitions, root: string): void {
210
+ for (const [name, def] of Object.entries(uniforms)) {
211
+ const prefix = `Uniform '${name}' in '${root}'`;
212
+
213
+ if (!def.type) {
214
+ throw new Error(`${prefix}: missing 'type' field`);
212
215
  }
213
- // Check for keyboard
214
- if (value === 'keyboard') {
215
- return { keyboard: true };
216
+
217
+ if (isArrayUniform(def)) {
218
+ if (!ARRAY_TYPES.has(def.type)) {
219
+ throw new Error(`${prefix}: invalid array type '${def.type}'. Expected one of: ${[...ARRAY_TYPES].join(', ')}`);
220
+ }
221
+ if (typeof def.count !== 'number' || def.count < 1 || !Number.isInteger(def.count)) {
222
+ throw new Error(`${prefix}: 'count' must be a positive integer, got ${def.count}`);
223
+ }
224
+ continue;
225
+ }
226
+
227
+ if (!SCALAR_TYPES.has(def.type)) {
228
+ throw new Error(`${prefix}: invalid type '${def.type}'. Expected one of: ${[...SCALAR_TYPES].join(', ')}`);
229
+ }
230
+
231
+ switch (def.type) {
232
+ case 'float':
233
+ case 'int':
234
+ if (typeof def.value !== 'number') {
235
+ throw new Error(`${prefix}: 'value' must be a number for type '${(def as any).type}', got ${typeof (def as any).value}`);
236
+ }
237
+ if (def.min !== undefined && typeof def.min !== 'number') {
238
+ throw new Error(`${prefix}: 'min' must be a number`);
239
+ }
240
+ if (def.max !== undefined && typeof def.max !== 'number') {
241
+ throw new Error(`${prefix}: 'max' must be a number`);
242
+ }
243
+ if (def.step !== undefined && typeof def.step !== 'number') {
244
+ throw new Error(`${prefix}: 'step' must be a number`);
245
+ }
246
+ break;
247
+
248
+ case 'bool':
249
+ if (typeof def.value !== 'boolean') {
250
+ throw new Error(`${prefix}: 'value' must be a boolean for type 'bool', got ${typeof (def as any).value}`);
251
+ }
252
+ break;
253
+
254
+ case 'vec2':
255
+ case 'vec3':
256
+ case 'vec4': {
257
+ const n = COMPONENT_COUNTS[def.type];
258
+ if (!Array.isArray(def.value) || def.value.length !== n) {
259
+ throw new Error(`${prefix}: 'value' must be an array of ${n} numbers for type '${def.type}'`);
260
+ }
261
+ if (def.value.some((v: any) => typeof v !== 'number')) {
262
+ throw new Error(`${prefix}: all components of 'value' must be numbers`);
263
+ }
264
+ const vecDef = def as { min?: number[]; max?: number[]; step?: number[] };
265
+ for (const field of ['min', 'max', 'step'] as const) {
266
+ const arr = vecDef[field];
267
+ if (arr !== undefined) {
268
+ if (!Array.isArray(arr) || arr.length !== n) {
269
+ throw new Error(`${prefix}: '${field}' must be an array of ${n} numbers for type '${def.type}'`);
270
+ }
271
+ }
272
+ }
273
+ break;
274
+ }
216
275
  }
217
- // Assume texture (file path)
218
- return { texture: value };
219
276
  }
220
- // Already an object
221
- return value;
222
277
  }
223
278
 
224
- /**
225
- * Load a project with config.json.
226
- *
227
- * @param root - Project directory
228
- * @param config - Parsed JSON config
229
- * @returns Normalized ShadertoyProject
230
- */
231
- async function loadProjectWithConfig(root: string, config: ShadertoyConfig): Promise<ShadertoyProject> {
232
- // Extract pass configs from top level
279
+ // =============================================================================
280
+ // Shadertoy Mode (iChannel-based)
281
+ // =============================================================================
282
+
283
+ async function loadShadertoyProject(root: string, config: ShadertoyConfig): Promise<ShaderProject> {
233
284
  const passConfigs = {
234
285
  Image: config.Image,
235
286
  BufferA: config.BufferA,
@@ -238,174 +289,302 @@ async function loadProjectWithConfig(root: string, config: ShadertoyConfig): Pro
238
289
  BufferD: config.BufferD,
239
290
  };
240
291
 
241
- // Validate: must have Image pass (or be empty config for simple shader)
242
292
  const hasAnyPass = passConfigs.Image || passConfigs.BufferA || passConfigs.BufferB ||
243
293
  passConfigs.BufferC || passConfigs.BufferD;
244
294
 
245
295
  if (!hasAnyPass) {
246
- // Empty config = simple Image pass with no channels
247
296
  passConfigs.Image = {};
248
297
  }
249
298
 
250
- // Resolve commonSource
251
- let commonSource: string | null = null;
252
- if (config.common) {
253
- const commonPath = path.join(root, config.common);
254
- if (!(await fileExists(commonPath))) {
255
- throw new Error(
256
- `Common GLSL file '${config.common}' not found in '${root}'.`
257
- );
258
- }
259
- commonSource = await fs.readFile(commonPath, 'utf8');
260
- } else {
261
- // Check for default common.glsl
262
- const defaultCommonPath = path.join(root, 'common.glsl');
263
- if (await fileExists(defaultCommonPath)) {
264
- commonSource = await fs.readFile(defaultCommonPath, 'utf8');
265
- }
266
- }
299
+ const commonSource = await resolveCommonSource(root, config.common);
267
300
 
268
- // Texture deduplication map
269
- const textureMap = new Map<string, ShadertoyTexture2D>();
301
+ // Texture deduplication
302
+ const textureMap = new Map<string, ShaderTexture2D>();
270
303
 
271
- /**
272
- * Register a texture and return its internal name.
273
- */
274
304
  function registerTexture(j: { texture: string; filter?: 'nearest' | 'linear'; wrap?: 'clamp' | 'repeat' }): string {
275
305
  const filter = j.filter ?? 'linear';
276
306
  const wrap = j.wrap ?? 'repeat';
277
307
  const key = `${j.texture}|${filter}|${wrap}`;
278
308
 
279
- let existing = textureMap.get(key);
280
- if (existing) {
281
- return existing.name;
282
- }
309
+ const existing = textureMap.get(key);
310
+ if (existing) return existing.name;
283
311
 
284
312
  const name = `tex${textureMap.size}`;
285
- const tex: ShadertoyTexture2D = {
286
- name,
287
- source: j.texture,
288
- filter,
289
- wrap,
290
- };
291
- textureMap.set(key, tex);
313
+ textureMap.set(key, { name, source: j.texture, filter, wrap });
292
314
  return name;
293
315
  }
294
316
 
295
- /**
296
- * Parse a channel object into ChannelSource.
297
- */
298
317
  function parseChannelObject(value: ChannelJSONObject, passName: PassName, channelKey: string): ChannelSource {
299
- // Buffer channel
300
318
  if ('buffer' in value) {
301
319
  const buf = value.buffer;
302
320
  if (!isPassName(buf)) {
303
- throw new Error(
304
- `Invalid buffer name '${buf}' for ${channelKey} in pass '${passName}' at '${root}'.`
305
- );
321
+ throw new Error(`Invalid buffer name '${buf}' for ${channelKey} in pass '${passName}' at '${root}'.`);
306
322
  }
307
- return {
308
- kind: 'buffer',
309
- buffer: buf,
310
- current: !!value.current,
311
- };
323
+ return { kind: 'buffer', buffer: buf, current: !!value.current };
312
324
  }
313
-
314
- // Texture channel
315
325
  if ('texture' in value) {
316
- const internalName = registerTexture(value);
317
- return {
318
- kind: 'texture',
319
- name: internalName,
320
- cubemap: value.type === 'cubemap',
321
- };
322
- }
323
-
324
- // Keyboard channel
325
- if ('keyboard' in value) {
326
- return { kind: 'keyboard' };
326
+ return { kind: 'texture', name: registerTexture(value), cubemap: value.type === 'cubemap' };
327
327
  }
328
+ if ('keyboard' in value) return { kind: 'keyboard' };
329
+ if ('audio' in value) return { kind: 'audio' };
330
+ if ('webcam' in value) return { kind: 'webcam' };
331
+ if ('video' in value) return { kind: 'video', src: value.video };
332
+ if ('script' in value) return { kind: 'script', name: value.script };
328
333
 
329
- throw new Error(
330
- `Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`
331
- );
334
+ throw new Error(`Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`);
332
335
  }
333
336
 
334
- /**
335
- * Load a single pass from simplified config.
336
- */
337
337
  async function loadPass(
338
338
  name: PassName,
339
339
  passConfig: PassConfigSimplified | undefined
340
- ): Promise<ShadertoyPass | undefined> {
340
+ ): Promise<ShaderPass | undefined> {
341
341
  if (!passConfig) return undefined;
342
342
 
343
343
  const sourceRel = passConfig.source ?? defaultSourceForPass(name);
344
344
  const sourcePath = path.join(root, sourceRel);
345
345
 
346
346
  if (!(await fileExists(sourcePath))) {
347
- throw new Error(
348
- `Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`
349
- );
347
+ throw new Error(`Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`);
350
348
  }
351
349
 
352
350
  const glslSource = await fs.readFile(sourcePath, 'utf8');
353
351
 
354
- // Normalize channels (always 4 channels)
355
352
  const channelSources: ChannelSource[] = [];
356
- const channelKeys = ['iChannel0', 'iChannel1', 'iChannel2', 'iChannel3'] as const;
357
-
358
- for (const key of channelKeys) {
353
+ for (const key of CHANNEL_KEYS) {
359
354
  const rawValue = passConfig[key];
360
355
  if (!rawValue) {
361
356
  channelSources.push({ kind: 'none' });
362
357
  continue;
363
358
  }
364
-
365
- // Parse string shorthand or use object directly
366
359
  const parsed = parseChannelValue(rawValue);
367
360
  if (!parsed) {
368
361
  channelSources.push({ kind: 'none' });
369
362
  continue;
370
363
  }
371
-
372
364
  channelSources.push(parseChannelObject(parsed, name, key));
373
365
  }
374
366
 
375
- return {
376
- name,
377
- glslSource,
378
- channels: channelSources as Channels,
379
- };
367
+ return { name, glslSource, channels: channelSources as Channels };
380
368
  }
381
369
 
382
- // Load all passes
383
370
  const imagePass = await loadPass('Image', passConfigs.Image);
384
371
  const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
385
372
  const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
386
373
  const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
387
374
  const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
388
375
 
389
- // If no Image pass was loaded but we have buffers, that's an error
390
376
  if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
391
377
  throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
392
378
  }
379
+ if (!imagePass) {
380
+ throw new Error(`config.json at '${root}' must define an Image pass.`);
381
+ }
382
+
383
+ const title = config.title ?? path.basename(root);
384
+
385
+ return {
386
+ mode: 'shadertoy',
387
+ root,
388
+ meta: { title, author: config.author ?? null, description: config.description ?? null },
389
+ layout: config.layout ?? DEFAULT_LAYOUT,
390
+ theme: config.theme ?? DEFAULT_THEME,
391
+ controls: config.controls ?? DEFAULT_CONTROLS,
392
+ startPaused: config.startPaused ?? false,
393
+ pixelRatio: config.pixelRatio ?? null,
394
+ commonSource,
395
+ passes: {
396
+ Image: imagePass,
397
+ BufferA: bufferAPass,
398
+ BufferB: bufferBPass,
399
+ BufferC: bufferCPass,
400
+ BufferD: bufferDPass,
401
+ },
402
+ textures: Array.from(textureMap.values()),
403
+ uniforms: {},
404
+ script: null,
405
+ };
406
+ }
407
+
408
+ // =============================================================================
409
+ // Standard Mode
410
+ // =============================================================================
411
+
412
+ /**
413
+ * Normalize buffers config: array shorthand to object form.
414
+ */
415
+ function normalizeBuffersConfig(
416
+ buffers: string[] | Record<string, StandardBufferConfig> | undefined
417
+ ): Record<string, StandardBufferConfig> {
418
+ if (!buffers) return {};
419
+ if (Array.isArray(buffers)) {
420
+ const result: Record<string, StandardBufferConfig> = {};
421
+ for (const name of buffers) {
422
+ result[name] = {};
423
+ }
424
+ return result;
425
+ }
426
+ return buffers;
427
+ }
428
+
429
+ /**
430
+ * Load a standard mode project.
431
+ * Supports both named buffers and pass-level configs (Image/BufferA/etc.).
432
+ */
433
+ async function loadStandardProject(root: string, config: StandardConfig): Promise<ShaderProject> {
434
+ // Validate uniforms early
435
+ if (config.uniforms) {
436
+ validateUniforms(config.uniforms, root);
437
+ }
438
+
439
+ const commonSource = await resolveCommonSource(root, config.common);
440
+
441
+ // Check if using named buffers or pass-level configs
442
+ const hasNamedBuffers = config.buffers && Object.keys(normalizeBuffersConfig(config.buffers)).length > 0;
443
+ const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
444
+ config.BufferC || config.BufferD;
445
+
446
+ if (hasNamedBuffers && hasPassConfigs) {
447
+ throw new Error(
448
+ `Standard mode at '${root}' cannot use both named 'buffers' and pass-level configs (Image/BufferA/etc.). Choose one approach.`
449
+ );
450
+ }
451
+
452
+ if (hasNamedBuffers) {
453
+ return loadStandardWithNamedBuffers(root, config, commonSource);
454
+ }
393
455
 
394
- // If still no Image pass, create empty one
456
+ if (hasPassConfigs) {
457
+ return loadStandardWithPassConfigs(root, config, commonSource);
458
+ }
459
+
460
+ // Simple single-pass standard project (config with settings only)
461
+ const imagePath = path.join(root, 'image.glsl');
462
+ if (!(await fileExists(imagePath))) {
463
+ throw new Error(`Standard mode project at '${root}' requires 'image.glsl'.`);
464
+ }
465
+ const imageSource = await fs.readFile(imagePath, 'utf8');
466
+ const title = config.title ?? path.basename(root);
467
+
468
+ return {
469
+ mode: 'standard',
470
+ root,
471
+ meta: { title, author: config.author ?? null, description: config.description ?? null },
472
+ layout: config.layout ?? DEFAULT_LAYOUT,
473
+ theme: config.theme ?? DEFAULT_THEME,
474
+ controls: config.controls ?? DEFAULT_CONTROLS,
475
+ startPaused: config.startPaused ?? false,
476
+ pixelRatio: config.pixelRatio ?? null,
477
+ commonSource,
478
+ passes: {
479
+ Image: {
480
+ name: 'Image',
481
+ glslSource: imageSource,
482
+ channels: [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }] as Channels,
483
+ },
484
+ },
485
+ textures: [],
486
+ uniforms: config.uniforms ?? {},
487
+ script: null,
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Standard mode with pass-level configs (Image, BufferA, etc.) + uniforms.
493
+ * Reuses the same iChannel mechanism as shadertoy mode but sets mode to 'standard'.
494
+ */
495
+ async function loadStandardWithPassConfigs(
496
+ root: string,
497
+ config: StandardConfig,
498
+ commonSource: string | null
499
+ ): Promise<ShaderProject> {
500
+ const passConfigs = {
501
+ Image: config.Image,
502
+ BufferA: config.BufferA,
503
+ BufferB: config.BufferB,
504
+ BufferC: config.BufferC,
505
+ BufferD: config.BufferD,
506
+ };
507
+
508
+ // Texture deduplication
509
+ const textureMap = new Map<string, ShaderTexture2D>();
510
+
511
+ function registerTexture(j: { texture: string; filter?: 'nearest' | 'linear'; wrap?: 'clamp' | 'repeat' }): string {
512
+ const filter = j.filter ?? 'linear';
513
+ const wrap = j.wrap ?? 'repeat';
514
+ const key = `${j.texture}|${filter}|${wrap}`;
515
+ const existing = textureMap.get(key);
516
+ if (existing) return existing.name;
517
+ const name = `tex${textureMap.size}`;
518
+ textureMap.set(key, { name, source: j.texture, filter, wrap });
519
+ return name;
520
+ }
521
+
522
+ function parseChannelObject(value: ChannelJSONObject, passName: PassName, channelKey: string): ChannelSource {
523
+ if ('buffer' in value) {
524
+ if (!isPassName(value.buffer)) {
525
+ throw new Error(`Invalid buffer name '${value.buffer}' for ${channelKey} in pass '${passName}' at '${root}'.`);
526
+ }
527
+ return { kind: 'buffer', buffer: value.buffer, current: !!value.current };
528
+ }
529
+ if ('texture' in value) {
530
+ return { kind: 'texture', name: registerTexture(value), cubemap: value.type === 'cubemap' };
531
+ }
532
+ if ('keyboard' in value) return { kind: 'keyboard' };
533
+ if ('audio' in value) return { kind: 'audio' };
534
+ if ('webcam' in value) return { kind: 'webcam' };
535
+ if ('video' in value) return { kind: 'video', src: value.video };
536
+ if ('script' in value) return { kind: 'script', name: value.script };
537
+ throw new Error(`Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`);
538
+ }
539
+
540
+ async function loadPass(
541
+ name: PassName,
542
+ passConfig: PassConfigSimplified | undefined
543
+ ): Promise<ShaderPass | undefined> {
544
+ if (!passConfig) return undefined;
545
+
546
+ const sourceRel = passConfig.source ?? defaultSourceForPass(name);
547
+ const sourcePath = path.join(root, sourceRel);
548
+ if (!(await fileExists(sourcePath))) {
549
+ throw new Error(`Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`);
550
+ }
551
+
552
+ const glslSource = await fs.readFile(sourcePath, 'utf8');
553
+ const channelSources: ChannelSource[] = [];
554
+ for (const key of CHANNEL_KEYS) {
555
+ const rawValue = passConfig[key];
556
+ if (!rawValue) { channelSources.push({ kind: 'none' }); continue; }
557
+ const parsed = parseChannelValue(rawValue);
558
+ if (!parsed) { channelSources.push({ kind: 'none' }); continue; }
559
+ channelSources.push(parseChannelObject(parsed, name, key));
560
+ }
561
+ return { name, glslSource, channels: channelSources as Channels };
562
+ }
563
+
564
+ const imagePass = await loadPass('Image', passConfigs.Image);
565
+ const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
566
+ const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
567
+ const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
568
+ const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
569
+
570
+ if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
571
+ throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
572
+ }
395
573
  if (!imagePass) {
396
574
  throw new Error(`config.json at '${root}' must define an Image pass.`);
397
575
  }
398
576
 
399
- // Build metadata
400
577
  const title = config.title ?? path.basename(root);
401
- const author = config.author ?? null;
402
- const description = config.description ?? null;
403
578
 
404
- const project: ShadertoyProject = {
579
+ return {
580
+ mode: 'standard',
405
581
  root,
406
- meta: { title, author, description },
407
- layout: config.layout ?? 'default',
408
- controls: config.controls ?? false,
582
+ meta: { title, author: config.author ?? null, description: config.description ?? null },
583
+ layout: config.layout ?? DEFAULT_LAYOUT,
584
+ theme: config.theme ?? DEFAULT_THEME,
585
+ controls: config.controls ?? DEFAULT_CONTROLS,
586
+ startPaused: config.startPaused ?? false,
587
+ pixelRatio: config.pixelRatio ?? null,
409
588
  commonSource,
410
589
  passes: {
411
590
  Image: imagePass,
@@ -415,7 +594,114 @@ async function loadProjectWithConfig(root: string, config: ShadertoyConfig): Pro
415
594
  BufferD: bufferDPass,
416
595
  },
417
596
  textures: Array.from(textureMap.values()),
597
+ uniforms: config.uniforms ?? {},
598
+ script: null,
599
+ };
600
+ }
601
+
602
+ /**
603
+ * Standard mode with named buffers and textures.
604
+ */
605
+ async function loadStandardWithNamedBuffers(
606
+ root: string,
607
+ config: StandardConfig,
608
+ commonSource: string | null
609
+ ): Promise<ShaderProject> {
610
+ const buffersConfig = normalizeBuffersConfig(config.buffers);
611
+ const bufferNames = Object.keys(buffersConfig);
612
+
613
+ if (bufferNames.length > 4) {
614
+ throw new Error(
615
+ `Standard mode at '${root}' supports max 4 buffers, got ${bufferNames.length}: ${bufferNames.join(', ')}`
616
+ );
617
+ }
618
+
619
+ const bufferNameToPass = new Map<string, PassName>();
620
+ for (let i = 0; i < bufferNames.length; i++) {
621
+ bufferNameToPass.set(bufferNames[i], BUFFER_PASS_NAMES[i]);
622
+ }
623
+
624
+ // Texture deduplication
625
+ const textureMap = new Map<string, ShaderTexture2D>();
626
+
627
+ function registerTexture(source: string, filter: 'nearest' | 'linear' = 'linear', wrap: 'clamp' | 'repeat' = 'repeat'): string {
628
+ const key = `${source}|${filter}|${wrap}`;
629
+ const existing = textureMap.get(key);
630
+ if (existing) return existing.name;
631
+ const name = `tex${textureMap.size}`;
632
+ textureMap.set(key, { name, source, filter, wrap });
633
+ return name;
634
+ }
635
+
636
+ // Build namedSamplers
637
+ const namedSamplers = new Map<string, ChannelSource>();
638
+
639
+ for (const [bufName, passName] of bufferNameToPass) {
640
+ namedSamplers.set(bufName, { kind: 'buffer', buffer: passName, current: false });
641
+ }
642
+
643
+ for (const [texName, texValue] of Object.entries(config.textures ?? {})) {
644
+ if (texValue === 'keyboard') {
645
+ namedSamplers.set(texName, { kind: 'keyboard' });
646
+ } else if (texValue === 'audio') {
647
+ namedSamplers.set(texName, { kind: 'audio' });
648
+ } else if (texValue === 'webcam') {
649
+ namedSamplers.set(texName, { kind: 'webcam' });
650
+ } else {
651
+ const internalName = registerTexture(texValue);
652
+ namedSamplers.set(texName, { kind: 'texture', name: internalName, cubemap: false });
653
+ }
654
+ }
655
+
656
+ const noChannels: Channels = [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }];
657
+
658
+ // Load Image pass
659
+ const imagePath = path.join(root, 'image.glsl');
660
+ if (!(await fileExists(imagePath))) {
661
+ throw new Error(`Standard mode project at '${root}' requires 'image.glsl'.`);
662
+ }
663
+ const imageSource = await fs.readFile(imagePath, 'utf8');
664
+
665
+ const passes: ShaderProject['passes'] = {
666
+ Image: {
667
+ name: 'Image',
668
+ glslSource: imageSource,
669
+ channels: noChannels,
670
+ namedSamplers: new Map(namedSamplers),
671
+ },
418
672
  };
419
673
 
420
- return project;
674
+ // Load buffer passes
675
+ for (const [bufName, passName] of bufferNameToPass) {
676
+ const sourcePath = path.join(root, `${bufName}.glsl`);
677
+ if (!(await fileExists(sourcePath))) {
678
+ throw new Error(`Buffer '${bufName}' requires '${bufName}.glsl' in '${root}'.`);
679
+ }
680
+ const glslSource = await fs.readFile(sourcePath, 'utf8');
681
+
682
+ passes[passName] = {
683
+ name: passName,
684
+ glslSource,
685
+ channels: noChannels,
686
+ namedSamplers: new Map(namedSamplers),
687
+ };
688
+ }
689
+
690
+ const title = config.title ?? path.basename(root);
691
+
692
+ return {
693
+ mode: 'standard',
694
+ root,
695
+ meta: { title, author: config.author ?? null, description: config.description ?? null },
696
+ layout: config.layout ?? DEFAULT_LAYOUT,
697
+ theme: config.theme ?? DEFAULT_THEME,
698
+ controls: config.controls ?? DEFAULT_CONTROLS,
699
+ startPaused: config.startPaused ?? false,
700
+ pixelRatio: config.pixelRatio ?? null,
701
+ commonSource,
702
+ passes,
703
+ textures: Array.from(textureMap.values()),
704
+ uniforms: config.uniforms ?? {},
705
+ script: null,
706
+ };
421
707
  }