@stevejtrettel/shader-sandbox 0.1.2 → 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 +259 -235
  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 +16 -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,13 +1,15 @@
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
8
8
  */
9
9
  import { promises as fs } from 'fs';
10
10
  import * as path from 'path';
11
+ import { isArrayUniform, } from './types';
12
+ import { isPassName, parseChannelValue, defaultSourceForPass, validateConfig, CHANNEL_KEYS, BUFFER_PASS_NAMES, DEFAULT_LAYOUT, DEFAULT_CONTROLS, DEFAULT_THEME, } from './configHelpers';
11
13
  // =============================================================================
12
14
  // Helper Functions
13
15
  // =============================================================================
@@ -23,12 +25,6 @@ async function fileExists(p) {
23
25
  return false;
24
26
  }
25
27
  }
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
28
  /**
33
29
  * List all .glsl files in a directory.
34
30
  */
@@ -49,41 +45,40 @@ async function hasTexturesDirWithFiles(root) {
49
45
  return entries.some((e) => e.isFile());
50
46
  }
51
47
  /**
52
- * Get default source file name for a pass.
48
+ * Resolve common source from config or default common.glsl.
53
49
  */
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';
50
+ async function resolveCommonSource(root, commonField) {
51
+ if (commonField) {
52
+ const commonPath = path.join(root, commonField);
53
+ if (!(await fileExists(commonPath))) {
54
+ throw new Error(`Common GLSL file '${commonField}' not found in '${root}'.`);
55
+ }
56
+ return await fs.readFile(commonPath, 'utf8');
57
+ }
58
+ const defaultCommonPath = path.join(root, 'common.glsl');
59
+ if (await fileExists(defaultCommonPath)) {
60
+ return await fs.readFile(defaultCommonPath, 'utf8');
66
61
  }
62
+ return null;
67
63
  }
68
64
  // =============================================================================
69
65
  // Main Entry Point
70
66
  // =============================================================================
71
67
  /**
72
- * Load a Shadertoy project from disk.
68
+ * Load a shader project from disk.
73
69
  *
74
70
  * Automatically detects:
75
71
  * - Single-pass mode (no config, just image.glsl)
76
72
  * - Multi-pass mode (config.json present)
77
73
  *
78
74
  * @param root - Absolute path to project directory
79
- * @returns Fully normalized ShadertoyProject
75
+ * @returns Fully normalized ShaderProject
80
76
  * @throws Error with descriptive message if project is invalid
81
77
  */
82
78
  export async function loadProject(root) {
83
79
  const configPath = path.join(root, 'config.json');
84
80
  const hasConfig = await fileExists(configPath);
85
81
  if (hasConfig) {
86
- // Multi-pass mode: parse config
87
82
  const raw = await fs.readFile(configPath, 'utf8');
88
83
  let config;
89
84
  try {
@@ -92,10 +87,13 @@ export async function loadProject(root) {
92
87
  catch (err) {
93
88
  throw new Error(`Invalid JSON in config.json at '${root}': ${err?.message ?? String(err)}`);
94
89
  }
95
- return await loadProjectWithConfig(root, config);
90
+ validateConfig(config, root);
91
+ if (config.mode === 'shadertoy') {
92
+ return await loadShadertoyProject(root, config);
93
+ }
94
+ return await loadStandardProject(root, config);
96
95
  }
97
96
  else {
98
- // Single-pass mode: just image.glsl
99
97
  return await loadSinglePassProject(root);
100
98
  }
101
99
  }
@@ -110,37 +108,31 @@ export async function loadProject(root) {
110
108
  * - Cannot have other .glsl files
111
109
  * - Cannot have textures/ directory
112
110
  * - No common.glsl allowed
113
- *
114
- * @param root - Project directory
115
- * @returns ShadertoyProject with only Image pass
116
111
  */
117
112
  async function loadSinglePassProject(root) {
118
113
  const imagePath = path.join(root, 'image.glsl');
119
114
  if (!(await fileExists(imagePath))) {
120
115
  throw new Error(`Single-pass project at '${root}' requires 'image.glsl'.`);
121
116
  }
122
- // Check for extra GLSL files
123
117
  const glslFiles = await listGlslFiles(root);
124
118
  const extraGlsl = glslFiles.filter((name) => name !== 'image.glsl');
125
119
  if (extraGlsl.length > 0) {
126
120
  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
121
  }
128
- // Check for textures
129
122
  if (await hasTexturesDirWithFiles(root)) {
130
123
  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
124
  }
132
- // Load shader source
133
125
  const imageSource = await fs.readFile(imagePath, 'utf8');
134
126
  const title = path.basename(root);
135
- const project = {
127
+ return {
128
+ mode: 'standard',
136
129
  root,
137
- meta: {
138
- title,
139
- author: null,
140
- description: null,
141
- },
142
- layout: 'default',
143
- controls: false,
130
+ meta: { title, author: null, description: null },
131
+ layout: DEFAULT_LAYOUT,
132
+ theme: DEFAULT_THEME,
133
+ controls: DEFAULT_CONTROLS,
134
+ startPaused: false,
135
+ pixelRatio: null,
144
136
  commonSource: null,
145
137
  passes: {
146
138
  Image: {
@@ -155,45 +147,85 @@ async function loadSinglePassProject(root) {
155
147
  },
156
148
  },
157
149
  textures: [],
150
+ uniforms: {},
151
+ script: null,
158
152
  };
159
- return project;
160
153
  }
161
154
  // =============================================================================
162
- // Multi-Pass Mode (With Config)
155
+ // Uniform Validation
163
156
  // =============================================================================
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 };
157
+ const SCALAR_TYPES = new Set(['float', 'int', 'bool', 'vec2', 'vec3', 'vec4']);
158
+ const ARRAY_TYPES = new Set(['float', 'vec2', 'vec3', 'vec4', 'mat3', 'mat4']);
159
+ const COMPONENT_COUNTS = {
160
+ vec2: 2, vec3: 3, vec4: 4,
161
+ };
162
+ function validateUniforms(uniforms, root) {
163
+ for (const [name, def] of Object.entries(uniforms)) {
164
+ const prefix = `Uniform '${name}' in '${root}'`;
165
+ if (!def.type) {
166
+ throw new Error(`${prefix}: missing 'type' field`);
167
+ }
168
+ if (isArrayUniform(def)) {
169
+ if (!ARRAY_TYPES.has(def.type)) {
170
+ throw new Error(`${prefix}: invalid array type '${def.type}'. Expected one of: ${[...ARRAY_TYPES].join(', ')}`);
171
+ }
172
+ if (typeof def.count !== 'number' || def.count < 1 || !Number.isInteger(def.count)) {
173
+ throw new Error(`${prefix}: 'count' must be a positive integer, got ${def.count}`);
174
+ }
175
+ continue;
176
+ }
177
+ if (!SCALAR_TYPES.has(def.type)) {
178
+ throw new Error(`${prefix}: invalid type '${def.type}'. Expected one of: ${[...SCALAR_TYPES].join(', ')}`);
177
179
  }
178
- // Check for keyboard
179
- if (value === 'keyboard') {
180
- return { keyboard: true };
180
+ switch (def.type) {
181
+ case 'float':
182
+ case 'int':
183
+ if (typeof def.value !== 'number') {
184
+ throw new Error(`${prefix}: 'value' must be a number for type '${def.type}', got ${typeof def.value}`);
185
+ }
186
+ if (def.min !== undefined && typeof def.min !== 'number') {
187
+ throw new Error(`${prefix}: 'min' must be a number`);
188
+ }
189
+ if (def.max !== undefined && typeof def.max !== 'number') {
190
+ throw new Error(`${prefix}: 'max' must be a number`);
191
+ }
192
+ if (def.step !== undefined && typeof def.step !== 'number') {
193
+ throw new Error(`${prefix}: 'step' must be a number`);
194
+ }
195
+ break;
196
+ case 'bool':
197
+ if (typeof def.value !== 'boolean') {
198
+ throw new Error(`${prefix}: 'value' must be a boolean for type 'bool', got ${typeof def.value}`);
199
+ }
200
+ break;
201
+ case 'vec2':
202
+ case 'vec3':
203
+ case 'vec4': {
204
+ const n = COMPONENT_COUNTS[def.type];
205
+ if (!Array.isArray(def.value) || def.value.length !== n) {
206
+ throw new Error(`${prefix}: 'value' must be an array of ${n} numbers for type '${def.type}'`);
207
+ }
208
+ if (def.value.some((v) => typeof v !== 'number')) {
209
+ throw new Error(`${prefix}: all components of 'value' must be numbers`);
210
+ }
211
+ const vecDef = def;
212
+ for (const field of ['min', 'max', 'step']) {
213
+ const arr = vecDef[field];
214
+ if (arr !== undefined) {
215
+ if (!Array.isArray(arr) || arr.length !== n) {
216
+ throw new Error(`${prefix}: '${field}' must be an array of ${n} numbers for type '${def.type}'`);
217
+ }
218
+ }
219
+ }
220
+ break;
221
+ }
181
222
  }
182
- // Assume texture (file path)
183
- return { texture: value };
184
223
  }
185
- // Already an object
186
- return value;
187
224
  }
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
225
+ // =============================================================================
226
+ // Shadertoy Mode (iChannel-based)
227
+ // =============================================================================
228
+ async function loadShadertoyProject(root, config) {
197
229
  const passConfigs = {
198
230
  Image: config.Image,
199
231
  BufferA: config.BufferA,
@@ -201,86 +233,224 @@ async function loadProjectWithConfig(root, config) {
201
233
  BufferC: config.BufferC,
202
234
  BufferD: config.BufferD,
203
235
  };
204
- // Validate: must have Image pass (or be empty config for simple shader)
205
236
  const hasAnyPass = passConfigs.Image || passConfigs.BufferA || passConfigs.BufferB ||
206
237
  passConfigs.BufferC || passConfigs.BufferD;
207
238
  if (!hasAnyPass) {
208
- // Empty config = simple Image pass with no channels
209
239
  passConfigs.Image = {};
210
240
  }
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
241
+ const commonSource = await resolveCommonSource(root, config.common);
242
+ // Texture deduplication
228
243
  const textureMap = new Map();
229
- /**
230
- * Register a texture and return its internal name.
231
- */
232
244
  function registerTexture(j) {
233
245
  const filter = j.filter ?? 'linear';
234
246
  const wrap = j.wrap ?? 'repeat';
235
247
  const key = `${j.texture}|${filter}|${wrap}`;
236
- let existing = textureMap.get(key);
237
- if (existing) {
248
+ const existing = textureMap.get(key);
249
+ if (existing)
238
250
  return existing.name;
239
- }
240
251
  const name = `tex${textureMap.size}`;
241
- const tex = {
242
- name,
243
- source: j.texture,
244
- filter,
245
- wrap,
246
- };
247
- textureMap.set(key, tex);
252
+ textureMap.set(key, { name, source: j.texture, filter, wrap });
248
253
  return name;
249
254
  }
250
- /**
251
- * Parse a channel object into ChannelSource.
252
- */
253
255
  function parseChannelObject(value, passName, channelKey) {
254
- // Buffer channel
255
256
  if ('buffer' in value) {
256
257
  const buf = value.buffer;
257
258
  if (!isPassName(buf)) {
258
259
  throw new Error(`Invalid buffer name '${buf}' for ${channelKey} in pass '${passName}' at '${root}'.`);
259
260
  }
260
- return {
261
- kind: 'buffer',
262
- buffer: buf,
263
- current: !!value.current,
264
- };
261
+ return { kind: 'buffer', buffer: buf, current: !!value.current };
265
262
  }
266
- // Texture channel
267
263
  if ('texture' in value) {
268
- const internalName = registerTexture(value);
269
- return {
270
- kind: 'texture',
271
- name: internalName,
272
- cubemap: value.type === 'cubemap',
273
- };
264
+ return { kind: 'texture', name: registerTexture(value), cubemap: value.type === 'cubemap' };
274
265
  }
275
- // Keyboard channel
276
- if ('keyboard' in value) {
266
+ if ('keyboard' in value)
277
267
  return { kind: 'keyboard' };
268
+ if ('audio' in value)
269
+ return { kind: 'audio' };
270
+ if ('webcam' in value)
271
+ return { kind: 'webcam' };
272
+ if ('video' in value)
273
+ return { kind: 'video', src: value.video };
274
+ if ('script' in value)
275
+ return { kind: 'script', name: value.script };
276
+ throw new Error(`Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`);
277
+ }
278
+ async function loadPass(name, passConfig) {
279
+ if (!passConfig)
280
+ return undefined;
281
+ const sourceRel = passConfig.source ?? defaultSourceForPass(name);
282
+ const sourcePath = path.join(root, sourceRel);
283
+ if (!(await fileExists(sourcePath))) {
284
+ throw new Error(`Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`);
285
+ }
286
+ const glslSource = await fs.readFile(sourcePath, 'utf8');
287
+ const channelSources = [];
288
+ for (const key of CHANNEL_KEYS) {
289
+ const rawValue = passConfig[key];
290
+ if (!rawValue) {
291
+ channelSources.push({ kind: 'none' });
292
+ continue;
293
+ }
294
+ const parsed = parseChannelValue(rawValue);
295
+ if (!parsed) {
296
+ channelSources.push({ kind: 'none' });
297
+ continue;
298
+ }
299
+ channelSources.push(parseChannelObject(parsed, name, key));
300
+ }
301
+ return { name, glslSource, channels: channelSources };
302
+ }
303
+ const imagePass = await loadPass('Image', passConfigs.Image);
304
+ const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
305
+ const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
306
+ const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
307
+ const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
308
+ if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
309
+ throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
310
+ }
311
+ if (!imagePass) {
312
+ throw new Error(`config.json at '${root}' must define an Image pass.`);
313
+ }
314
+ const title = config.title ?? path.basename(root);
315
+ return {
316
+ mode: 'shadertoy',
317
+ root,
318
+ meta: { title, author: config.author ?? null, description: config.description ?? null },
319
+ layout: config.layout ?? DEFAULT_LAYOUT,
320
+ theme: config.theme ?? DEFAULT_THEME,
321
+ controls: config.controls ?? DEFAULT_CONTROLS,
322
+ startPaused: config.startPaused ?? false,
323
+ pixelRatio: config.pixelRatio ?? null,
324
+ commonSource,
325
+ passes: {
326
+ Image: imagePass,
327
+ BufferA: bufferAPass,
328
+ BufferB: bufferBPass,
329
+ BufferC: bufferCPass,
330
+ BufferD: bufferDPass,
331
+ },
332
+ textures: Array.from(textureMap.values()),
333
+ uniforms: {},
334
+ script: null,
335
+ };
336
+ }
337
+ // =============================================================================
338
+ // Standard Mode
339
+ // =============================================================================
340
+ /**
341
+ * Normalize buffers config: array shorthand to object form.
342
+ */
343
+ function normalizeBuffersConfig(buffers) {
344
+ if (!buffers)
345
+ return {};
346
+ if (Array.isArray(buffers)) {
347
+ const result = {};
348
+ for (const name of buffers) {
349
+ result[name] = {};
278
350
  }
351
+ return result;
352
+ }
353
+ return buffers;
354
+ }
355
+ /**
356
+ * Load a standard mode project.
357
+ * Supports both named buffers and pass-level configs (Image/BufferA/etc.).
358
+ */
359
+ async function loadStandardProject(root, config) {
360
+ // Validate uniforms early
361
+ if (config.uniforms) {
362
+ validateUniforms(config.uniforms, root);
363
+ }
364
+ const commonSource = await resolveCommonSource(root, config.common);
365
+ // Check if using named buffers or pass-level configs
366
+ const hasNamedBuffers = config.buffers && Object.keys(normalizeBuffersConfig(config.buffers)).length > 0;
367
+ const hasPassConfigs = config.Image || config.BufferA || config.BufferB ||
368
+ config.BufferC || config.BufferD;
369
+ if (hasNamedBuffers && hasPassConfigs) {
370
+ throw new Error(`Standard mode at '${root}' cannot use both named 'buffers' and pass-level configs (Image/BufferA/etc.). Choose one approach.`);
371
+ }
372
+ if (hasNamedBuffers) {
373
+ return loadStandardWithNamedBuffers(root, config, commonSource);
374
+ }
375
+ if (hasPassConfigs) {
376
+ return loadStandardWithPassConfigs(root, config, commonSource);
377
+ }
378
+ // Simple single-pass standard project (config with settings only)
379
+ const imagePath = path.join(root, 'image.glsl');
380
+ if (!(await fileExists(imagePath))) {
381
+ throw new Error(`Standard mode project at '${root}' requires 'image.glsl'.`);
382
+ }
383
+ const imageSource = await fs.readFile(imagePath, 'utf8');
384
+ const title = config.title ?? path.basename(root);
385
+ return {
386
+ mode: 'standard',
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: {
397
+ name: 'Image',
398
+ glslSource: imageSource,
399
+ channels: [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }],
400
+ },
401
+ },
402
+ textures: [],
403
+ uniforms: config.uniforms ?? {},
404
+ script: null,
405
+ };
406
+ }
407
+ /**
408
+ * Standard mode with pass-level configs (Image, BufferA, etc.) + uniforms.
409
+ * Reuses the same iChannel mechanism as shadertoy mode but sets mode to 'standard'.
410
+ */
411
+ async function loadStandardWithPassConfigs(root, config, commonSource) {
412
+ const passConfigs = {
413
+ Image: config.Image,
414
+ BufferA: config.BufferA,
415
+ BufferB: config.BufferB,
416
+ BufferC: config.BufferC,
417
+ BufferD: config.BufferD,
418
+ };
419
+ // Texture deduplication
420
+ const textureMap = new Map();
421
+ function registerTexture(j) {
422
+ const filter = j.filter ?? 'linear';
423
+ const wrap = j.wrap ?? 'repeat';
424
+ const key = `${j.texture}|${filter}|${wrap}`;
425
+ const existing = textureMap.get(key);
426
+ if (existing)
427
+ return existing.name;
428
+ const name = `tex${textureMap.size}`;
429
+ textureMap.set(key, { name, source: j.texture, filter, wrap });
430
+ return name;
431
+ }
432
+ function parseChannelObject(value, passName, channelKey) {
433
+ if ('buffer' in value) {
434
+ if (!isPassName(value.buffer)) {
435
+ throw new Error(`Invalid buffer name '${value.buffer}' for ${channelKey} in pass '${passName}' at '${root}'.`);
436
+ }
437
+ return { kind: 'buffer', buffer: value.buffer, current: !!value.current };
438
+ }
439
+ if ('texture' in value) {
440
+ return { kind: 'texture', name: registerTexture(value), cubemap: value.type === 'cubemap' };
441
+ }
442
+ if ('keyboard' in value)
443
+ return { kind: 'keyboard' };
444
+ if ('audio' in value)
445
+ return { kind: 'audio' };
446
+ if ('webcam' in value)
447
+ return { kind: 'webcam' };
448
+ if ('video' in value)
449
+ return { kind: 'video', src: value.video };
450
+ if ('script' in value)
451
+ return { kind: 'script', name: value.script };
279
452
  throw new Error(`Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`);
280
453
  }
281
- /**
282
- * Load a single pass from simplified config.
283
- */
284
454
  async function loadPass(name, passConfig) {
285
455
  if (!passConfig)
286
456
  return undefined;
@@ -290,16 +460,13 @@ async function loadProjectWithConfig(root, config) {
290
460
  throw new Error(`Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`);
291
461
  }
292
462
  const glslSource = await fs.readFile(sourcePath, 'utf8');
293
- // Normalize channels (always 4 channels)
294
463
  const channelSources = [];
295
- const channelKeys = ['iChannel0', 'iChannel1', 'iChannel2', 'iChannel3'];
296
- for (const key of channelKeys) {
464
+ for (const key of CHANNEL_KEYS) {
297
465
  const rawValue = passConfig[key];
298
466
  if (!rawValue) {
299
467
  channelSources.push({ kind: 'none' });
300
468
  continue;
301
469
  }
302
- // Parse string shorthand or use object directly
303
470
  const parsed = parseChannelValue(rawValue);
304
471
  if (!parsed) {
305
472
  channelSources.push({ kind: 'none' });
@@ -307,35 +474,29 @@ async function loadProjectWithConfig(root, config) {
307
474
  }
308
475
  channelSources.push(parseChannelObject(parsed, name, key));
309
476
  }
310
- return {
311
- name,
312
- glslSource,
313
- channels: channelSources,
314
- };
477
+ return { name, glslSource, channels: channelSources };
315
478
  }
316
- // Load all passes
317
479
  const imagePass = await loadPass('Image', passConfigs.Image);
318
480
  const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
319
481
  const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
320
482
  const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
321
483
  const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
322
- // If no Image pass was loaded but we have buffers, that's an error
323
484
  if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
324
485
  throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
325
486
  }
326
- // If still no Image pass, create empty one
327
487
  if (!imagePass) {
328
488
  throw new Error(`config.json at '${root}' must define an Image pass.`);
329
489
  }
330
- // Build metadata
331
490
  const title = config.title ?? path.basename(root);
332
- const author = config.author ?? null;
333
- const description = config.description ?? null;
334
- const project = {
491
+ return {
492
+ mode: 'standard',
335
493
  root,
336
- meta: { title, author, description },
337
- layout: config.layout ?? 'default',
338
- controls: config.controls ?? false,
494
+ meta: { title, author: config.author ?? null, description: config.description ?? null },
495
+ layout: config.layout ?? DEFAULT_LAYOUT,
496
+ theme: config.theme ?? DEFAULT_THEME,
497
+ controls: config.controls ?? DEFAULT_CONTROLS,
498
+ startPaused: config.startPaused ?? false,
499
+ pixelRatio: config.pixelRatio ?? null,
339
500
  commonSource,
340
501
  passes: {
341
502
  Image: imagePass,
@@ -345,6 +506,97 @@ async function loadProjectWithConfig(root, config) {
345
506
  BufferD: bufferDPass,
346
507
  },
347
508
  textures: Array.from(textureMap.values()),
509
+ uniforms: config.uniforms ?? {},
510
+ script: null,
511
+ };
512
+ }
513
+ /**
514
+ * Standard mode with named buffers and textures.
515
+ */
516
+ async function loadStandardWithNamedBuffers(root, config, commonSource) {
517
+ const buffersConfig = normalizeBuffersConfig(config.buffers);
518
+ const bufferNames = Object.keys(buffersConfig);
519
+ if (bufferNames.length > 4) {
520
+ throw new Error(`Standard mode at '${root}' supports max 4 buffers, got ${bufferNames.length}: ${bufferNames.join(', ')}`);
521
+ }
522
+ const bufferNameToPass = new Map();
523
+ for (let i = 0; i < bufferNames.length; i++) {
524
+ bufferNameToPass.set(bufferNames[i], BUFFER_PASS_NAMES[i]);
525
+ }
526
+ // Texture deduplication
527
+ const textureMap = new Map();
528
+ function registerTexture(source, filter = 'linear', wrap = 'repeat') {
529
+ const key = `${source}|${filter}|${wrap}`;
530
+ const existing = textureMap.get(key);
531
+ if (existing)
532
+ return existing.name;
533
+ const name = `tex${textureMap.size}`;
534
+ textureMap.set(key, { name, source, filter, wrap });
535
+ return name;
536
+ }
537
+ // Build namedSamplers
538
+ const namedSamplers = new Map();
539
+ for (const [bufName, passName] of bufferNameToPass) {
540
+ namedSamplers.set(bufName, { kind: 'buffer', buffer: passName, current: false });
541
+ }
542
+ for (const [texName, texValue] of Object.entries(config.textures ?? {})) {
543
+ if (texValue === 'keyboard') {
544
+ namedSamplers.set(texName, { kind: 'keyboard' });
545
+ }
546
+ else if (texValue === 'audio') {
547
+ namedSamplers.set(texName, { kind: 'audio' });
548
+ }
549
+ else if (texValue === 'webcam') {
550
+ namedSamplers.set(texName, { kind: 'webcam' });
551
+ }
552
+ else {
553
+ const internalName = registerTexture(texValue);
554
+ namedSamplers.set(texName, { kind: 'texture', name: internalName, cubemap: false });
555
+ }
556
+ }
557
+ const noChannels = [{ kind: 'none' }, { kind: 'none' }, { kind: 'none' }, { kind: 'none' }];
558
+ // Load Image pass
559
+ const imagePath = path.join(root, 'image.glsl');
560
+ if (!(await fileExists(imagePath))) {
561
+ throw new Error(`Standard mode project at '${root}' requires 'image.glsl'.`);
562
+ }
563
+ const imageSource = await fs.readFile(imagePath, 'utf8');
564
+ const passes = {
565
+ Image: {
566
+ name: 'Image',
567
+ glslSource: imageSource,
568
+ channels: noChannels,
569
+ namedSamplers: new Map(namedSamplers),
570
+ },
571
+ };
572
+ // Load buffer passes
573
+ for (const [bufName, passName] of bufferNameToPass) {
574
+ const sourcePath = path.join(root, `${bufName}.glsl`);
575
+ if (!(await fileExists(sourcePath))) {
576
+ throw new Error(`Buffer '${bufName}' requires '${bufName}.glsl' in '${root}'.`);
577
+ }
578
+ const glslSource = await fs.readFile(sourcePath, 'utf8');
579
+ passes[passName] = {
580
+ name: passName,
581
+ glslSource,
582
+ channels: noChannels,
583
+ namedSamplers: new Map(namedSamplers),
584
+ };
585
+ }
586
+ const title = config.title ?? path.basename(root);
587
+ return {
588
+ mode: 'standard',
589
+ root,
590
+ meta: { title, author: config.author ?? null, description: config.description ?? null },
591
+ layout: config.layout ?? DEFAULT_LAYOUT,
592
+ theme: config.theme ?? DEFAULT_THEME,
593
+ controls: config.controls ?? DEFAULT_CONTROLS,
594
+ startPaused: config.startPaused ?? false,
595
+ pixelRatio: config.pixelRatio ?? null,
596
+ commonSource,
597
+ passes,
598
+ textures: Array.from(textureMap.values()),
599
+ uniforms: config.uniforms ?? {},
600
+ script: null,
348
601
  };
349
- return project;
350
602
  }