@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,23 @@
1
+ // Auto-generated - DO NOT EDIT
2
+ import { loadDemo } from './loaderHelper';
3
+ import { ShadertoyConfig } from './types';
4
+
5
+ export const DEMO_NAME = 'course/day5/torus-analytical';
6
+
7
+ export async function loadDemoProject() {
8
+ const glslFiles = import.meta.glob<string>('/demos/course/day5/torus-analytical/**/*.glsl', {
9
+ query: '?raw',
10
+ import: 'default',
11
+ });
12
+
13
+ const jsonFiles = import.meta.glob<ShadertoyConfig>('/demos/course/day5/torus-analytical/**/*.json', {
14
+ import: 'default',
15
+ });
16
+
17
+ const imageFiles = import.meta.glob<string>('/demos/course/day5/torus-analytical/**/*.{jpg,jpeg,png,gif,webp,bmp}', {
18
+ query: '?url',
19
+ import: 'default',
20
+ });
21
+
22
+ return loadDemo(DEMO_NAME, glslFiles, jsonFiles, imageFiles);
23
+ }
@@ -0,0 +1,421 @@
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
+
10
+ import { promises as fs } from 'fs';
11
+ import * as path from 'path';
12
+ import {
13
+ PassName,
14
+ ChannelSource,
15
+ Channels,
16
+ ChannelValue,
17
+ ChannelJSONObject,
18
+ ShadertoyConfig,
19
+ ShadertoyPass,
20
+ ShadertoyProject,
21
+ ShadertoyTexture2D,
22
+ PassConfigSimplified,
23
+ } from './types';
24
+
25
+ // =============================================================================
26
+ // Helper Functions
27
+ // =============================================================================
28
+
29
+ /**
30
+ * Check if a file exists.
31
+ */
32
+ async function fileExists(p: string): Promise<boolean> {
33
+ try {
34
+ await fs.access(p);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
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
+ /**
49
+ * List all .glsl files in a directory.
50
+ */
51
+ async function listGlslFiles(root: string): Promise<string[]> {
52
+ const entries = await fs.readdir(root, { withFileTypes: true });
53
+ return entries
54
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.glsl'))
55
+ .map((e) => e.name);
56
+ }
57
+
58
+ /**
59
+ * Check if project has a textures/ directory with files.
60
+ */
61
+ async function hasTexturesDirWithFiles(root: string): Promise<boolean> {
62
+ const dir = path.join(root, 'textures');
63
+ if (!(await fileExists(dir))) return false;
64
+ const entries = await fs.readdir(dir, { withFileTypes: true });
65
+ return entries.some((e) => e.isFile());
66
+ }
67
+
68
+ /**
69
+ * Get default source file name for a pass.
70
+ */
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';
83
+ }
84
+ }
85
+
86
+ // =============================================================================
87
+ // Main Entry Point
88
+ // =============================================================================
89
+
90
+ /**
91
+ * Load a Shadertoy project from disk.
92
+ *
93
+ * Automatically detects:
94
+ * - Single-pass mode (no config, just image.glsl)
95
+ * - Multi-pass mode (config.json present)
96
+ *
97
+ * @param root - Absolute path to project directory
98
+ * @returns Fully normalized ShadertoyProject
99
+ * @throws Error with descriptive message if project is invalid
100
+ */
101
+ export async function loadProject(root: string): Promise<ShadertoyProject> {
102
+ const configPath = path.join(root, 'config.json');
103
+ const hasConfig = await fileExists(configPath);
104
+
105
+ if (hasConfig) {
106
+ // Multi-pass mode: parse config
107
+ const raw = await fs.readFile(configPath, 'utf8');
108
+ let config: ShadertoyConfig;
109
+ try {
110
+ config = JSON.parse(raw);
111
+ } catch (err: any) {
112
+ throw new Error(
113
+ `Invalid JSON in config.json at '${root}': ${err?.message ?? String(err)}`
114
+ );
115
+ }
116
+ return await loadProjectWithConfig(root, config);
117
+ } else {
118
+ // Single-pass mode: just image.glsl
119
+ return await loadSinglePassProject(root);
120
+ }
121
+ }
122
+
123
+ // =============================================================================
124
+ // Single-Pass Mode (No Config)
125
+ // =============================================================================
126
+
127
+ /**
128
+ * Load a simple single-pass project.
129
+ *
130
+ * Requirements:
131
+ * - Must have image.glsl
132
+ * - Cannot have other .glsl files
133
+ * - Cannot have textures/ directory
134
+ * - No common.glsl allowed
135
+ *
136
+ * @param root - Project directory
137
+ * @returns ShadertoyProject with only Image pass
138
+ */
139
+ async function loadSinglePassProject(root: string): Promise<ShadertoyProject> {
140
+ const imagePath = path.join(root, 'image.glsl');
141
+ if (!(await fileExists(imagePath))) {
142
+ throw new Error(`Single-pass project at '${root}' requires 'image.glsl'.`);
143
+ }
144
+
145
+ // Check for extra GLSL files
146
+ const glslFiles = await listGlslFiles(root);
147
+ const extraGlsl = glslFiles.filter((name) => name !== 'image.glsl');
148
+ if (extraGlsl.length > 0) {
149
+ throw new Error(
150
+ `Project at '${root}' contains multiple GLSL files (${glslFiles.join(
151
+ ', '
152
+ )}) but no 'config.json'. Add a config file to use multiple passes.`
153
+ );
154
+ }
155
+
156
+ // Check for textures
157
+ if (await hasTexturesDirWithFiles(root)) {
158
+ throw new Error(
159
+ `Project at '${root}' uses textures (in 'textures/' folder) but has no 'config.json'. Add a config file to define texture bindings.`
160
+ );
161
+ }
162
+
163
+ // Load shader source
164
+ const imageSource = await fs.readFile(imagePath, 'utf8');
165
+ const title = path.basename(root);
166
+
167
+ const project: ShadertoyProject = {
168
+ root,
169
+ meta: {
170
+ title,
171
+ author: null,
172
+ description: null,
173
+ },
174
+ layout: 'default',
175
+ controls: false,
176
+ commonSource: null,
177
+ passes: {
178
+ Image: {
179
+ name: 'Image',
180
+ glslSource: imageSource,
181
+ channels: [
182
+ { kind: 'none' },
183
+ { kind: 'none' },
184
+ { kind: 'none' },
185
+ { kind: 'none' },
186
+ ] as Channels,
187
+ },
188
+ },
189
+ textures: [],
190
+ };
191
+
192
+ return project;
193
+ }
194
+
195
+ // =============================================================================
196
+ // Multi-Pass Mode (With Config)
197
+ // =============================================================================
198
+
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 };
212
+ }
213
+ // Check for keyboard
214
+ if (value === 'keyboard') {
215
+ return { keyboard: true };
216
+ }
217
+ // Assume texture (file path)
218
+ return { texture: value };
219
+ }
220
+ // Already an object
221
+ return value;
222
+ }
223
+
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
233
+ const passConfigs = {
234
+ Image: config.Image,
235
+ BufferA: config.BufferA,
236
+ BufferB: config.BufferB,
237
+ BufferC: config.BufferC,
238
+ BufferD: config.BufferD,
239
+ };
240
+
241
+ // Validate: must have Image pass (or be empty config for simple shader)
242
+ const hasAnyPass = passConfigs.Image || passConfigs.BufferA || passConfigs.BufferB ||
243
+ passConfigs.BufferC || passConfigs.BufferD;
244
+
245
+ if (!hasAnyPass) {
246
+ // Empty config = simple Image pass with no channels
247
+ passConfigs.Image = {};
248
+ }
249
+
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
+ }
267
+
268
+ // Texture deduplication map
269
+ const textureMap = new Map<string, ShadertoyTexture2D>();
270
+
271
+ /**
272
+ * Register a texture and return its internal name.
273
+ */
274
+ function registerTexture(j: { texture: string; filter?: 'nearest' | 'linear'; wrap?: 'clamp' | 'repeat' }): string {
275
+ const filter = j.filter ?? 'linear';
276
+ const wrap = j.wrap ?? 'repeat';
277
+ const key = `${j.texture}|${filter}|${wrap}`;
278
+
279
+ let existing = textureMap.get(key);
280
+ if (existing) {
281
+ return existing.name;
282
+ }
283
+
284
+ 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);
292
+ return name;
293
+ }
294
+
295
+ /**
296
+ * Parse a channel object into ChannelSource.
297
+ */
298
+ function parseChannelObject(value: ChannelJSONObject, passName: PassName, channelKey: string): ChannelSource {
299
+ // Buffer channel
300
+ if ('buffer' in value) {
301
+ const buf = value.buffer;
302
+ if (!isPassName(buf)) {
303
+ throw new Error(
304
+ `Invalid buffer name '${buf}' for ${channelKey} in pass '${passName}' at '${root}'.`
305
+ );
306
+ }
307
+ return {
308
+ kind: 'buffer',
309
+ buffer: buf,
310
+ current: !!value.current,
311
+ };
312
+ }
313
+
314
+ // Texture channel
315
+ 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' };
327
+ }
328
+
329
+ throw new Error(
330
+ `Invalid channel object for ${channelKey} in pass '${passName}' at '${root}'.`
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Load a single pass from simplified config.
336
+ */
337
+ async function loadPass(
338
+ name: PassName,
339
+ passConfig: PassConfigSimplified | undefined
340
+ ): Promise<ShadertoyPass | undefined> {
341
+ if (!passConfig) return undefined;
342
+
343
+ const sourceRel = passConfig.source ?? defaultSourceForPass(name);
344
+ const sourcePath = path.join(root, sourceRel);
345
+
346
+ if (!(await fileExists(sourcePath))) {
347
+ throw new Error(
348
+ `Source GLSL file for pass '${name}' not found at '${sourceRel}' in '${root}'.`
349
+ );
350
+ }
351
+
352
+ const glslSource = await fs.readFile(sourcePath, 'utf8');
353
+
354
+ // Normalize channels (always 4 channels)
355
+ const channelSources: ChannelSource[] = [];
356
+ const channelKeys = ['iChannel0', 'iChannel1', 'iChannel2', 'iChannel3'] as const;
357
+
358
+ for (const key of channelKeys) {
359
+ const rawValue = passConfig[key];
360
+ if (!rawValue) {
361
+ channelSources.push({ kind: 'none' });
362
+ continue;
363
+ }
364
+
365
+ // Parse string shorthand or use object directly
366
+ const parsed = parseChannelValue(rawValue);
367
+ if (!parsed) {
368
+ channelSources.push({ kind: 'none' });
369
+ continue;
370
+ }
371
+
372
+ channelSources.push(parseChannelObject(parsed, name, key));
373
+ }
374
+
375
+ return {
376
+ name,
377
+ glslSource,
378
+ channels: channelSources as Channels,
379
+ };
380
+ }
381
+
382
+ // Load all passes
383
+ const imagePass = await loadPass('Image', passConfigs.Image);
384
+ const bufferAPass = await loadPass('BufferA', passConfigs.BufferA);
385
+ const bufferBPass = await loadPass('BufferB', passConfigs.BufferB);
386
+ const bufferCPass = await loadPass('BufferC', passConfigs.BufferC);
387
+ const bufferDPass = await loadPass('BufferD', passConfigs.BufferD);
388
+
389
+ // If no Image pass was loaded but we have buffers, that's an error
390
+ if (!imagePass && (bufferAPass || bufferBPass || bufferCPass || bufferDPass)) {
391
+ throw new Error(`config.json at '${root}' has buffers but no Image pass.`);
392
+ }
393
+
394
+ // If still no Image pass, create empty one
395
+ if (!imagePass) {
396
+ throw new Error(`config.json at '${root}' must define an Image pass.`);
397
+ }
398
+
399
+ // Build metadata
400
+ const title = config.title ?? path.basename(root);
401
+ const author = config.author ?? null;
402
+ const description = config.description ?? null;
403
+
404
+ const project: ShadertoyProject = {
405
+ root,
406
+ meta: { title, author, description },
407
+ layout: config.layout ?? 'default',
408
+ controls: config.controls ?? false,
409
+ commonSource,
410
+ passes: {
411
+ Image: imagePass,
412
+ BufferA: bufferAPass,
413
+ BufferB: bufferBPass,
414
+ BufferC: bufferCPass,
415
+ BufferD: bufferDPass,
416
+ },
417
+ textures: Array.from(textureMap.values()),
418
+ };
419
+
420
+ return project;
421
+ }