vizcore 0.1.0 → 1.0.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,103 @@
1
+ export const VISUAL_SETTINGS_PRESET_KEY = "vizcore.visualSettings.v1";
2
+ export const VISUAL_SETTINGS_PRESET_VERSION = 1;
3
+
4
+ export const DEFAULT_VISUAL_SETTINGS = Object.freeze({
5
+ visualGain: 2.5,
6
+ bassBoost: 1.4,
7
+ smoothing: 0.25,
8
+ beatHoldMs: 180,
9
+ wobbleAmount: 1.0,
10
+ });
11
+
12
+ export const VISUAL_SETTING_LIMITS = Object.freeze({
13
+ visualGain: [1, 8],
14
+ bassBoost: [0, 4],
15
+ smoothing: [0, 0.9],
16
+ beatHoldMs: [50, 400],
17
+ wobbleAmount: [0.25, 3],
18
+ });
19
+
20
+ export const normalizeVisualSettings = (value, fallback = DEFAULT_VISUAL_SETTINGS) => {
21
+ const input = value && typeof value === "object" ? value : {};
22
+
23
+ return Object.fromEntries(
24
+ Object.entries(DEFAULT_VISUAL_SETTINGS).map(([key, defaultValue]) => {
25
+ const [min, max] = VISUAL_SETTING_LIMITS[key];
26
+ const fallbackValue = Number(fallback?.[key] ?? defaultValue);
27
+ return [key, clampNumber(input[key], min, max, fallbackValue)];
28
+ })
29
+ );
30
+ };
31
+
32
+ export const loadVisualSettingsPreset = (storage, {
33
+ key = VISUAL_SETTINGS_PRESET_KEY,
34
+ fallback = DEFAULT_VISUAL_SETTINGS,
35
+ } = {}) => {
36
+ if (!storage) {
37
+ return normalizeVisualSettings(fallback);
38
+ }
39
+
40
+ try {
41
+ const rawValue = storage.getItem(key);
42
+ if (!rawValue) {
43
+ return normalizeVisualSettings(fallback);
44
+ }
45
+ return normalizeVisualSettings(JSON.parse(rawValue), fallback);
46
+ } catch {
47
+ return normalizeVisualSettings(fallback);
48
+ }
49
+ };
50
+
51
+ export const saveVisualSettingsPreset = (storage, settings, {
52
+ key = VISUAL_SETTINGS_PRESET_KEY,
53
+ } = {}) => {
54
+ const normalized = normalizeVisualSettings(settings);
55
+ if (!storage) {
56
+ return normalized;
57
+ }
58
+
59
+ try {
60
+ storage.setItem(key, JSON.stringify(normalized));
61
+ } catch {
62
+ // Ignore storage failures; the active in-memory settings still apply.
63
+ }
64
+ return normalized;
65
+ };
66
+
67
+ export const exportVisualSettingsPreset = (settings) => {
68
+ return JSON.stringify({
69
+ version: VISUAL_SETTINGS_PRESET_VERSION,
70
+ visual_settings: normalizeVisualSettings(settings),
71
+ }, null, 2);
72
+ };
73
+
74
+ export const importVisualSettingsPreset = (rawValue, {
75
+ fallback = DEFAULT_VISUAL_SETTINGS,
76
+ } = {}) => {
77
+ try {
78
+ const parsed = typeof rawValue === "string" ? JSON.parse(rawValue) : rawValue;
79
+ const settings = parsed?.visual_settings || parsed?.settings || parsed;
80
+ return normalizeVisualSettings(settings, fallback);
81
+ } catch {
82
+ return normalizeVisualSettings(fallback);
83
+ }
84
+ };
85
+
86
+ export const visualSettingFromUnit = (key, unitValue, fallback = DEFAULT_VISUAL_SETTINGS[key]) => {
87
+ const limits = VISUAL_SETTING_LIMITS[key];
88
+ if (!limits) {
89
+ return Number(fallback ?? 0);
90
+ }
91
+
92
+ const [min, max] = limits;
93
+ const amount = clampNumber(unitValue, 0, 1, 0);
94
+ return clampNumber(min + (max - min) * amount, min, max, fallback);
95
+ };
96
+
97
+ const clampNumber = (value, min, max, fallback) => {
98
+ const numeric = Number(value);
99
+ if (!Number.isFinite(numeric)) {
100
+ return fallback;
101
+ }
102
+ return Math.min(Math.max(numeric, min), max);
103
+ };
@@ -15,6 +15,58 @@ const EDGES = [
15
15
  [0, 4], [1, 5], [2, 6], [3, 7]
16
16
  ];
17
17
 
18
+ const TETRAHEDRON_VERTICES = [
19
+ [1, 1, 1],
20
+ [-1, -1, 1],
21
+ [-1, 1, -1],
22
+ [1, -1, -1]
23
+ ].map(([x, y, z]) => [x / Math.sqrt(3), y / Math.sqrt(3), z / Math.sqrt(3)]);
24
+
25
+ const TETRAHEDRON_EDGES = [
26
+ [0, 1], [0, 2], [0, 3],
27
+ [1, 2], [1, 3], [2, 3]
28
+ ];
29
+
30
+ const OCTAHEDRON_VERTICES = [
31
+ [1, 0, 0], [-1, 0, 0],
32
+ [0, 1, 0], [0, -1, 0],
33
+ [0, 0, 1], [0, 0, -1]
34
+ ];
35
+
36
+ const OCTAHEDRON_EDGES = [
37
+ [0, 2], [0, 3], [0, 4], [0, 5],
38
+ [1, 2], [1, 3], [1, 4], [1, 5],
39
+ [2, 4], [2, 5], [3, 4], [3, 5]
40
+ ];
41
+
42
+ const PHI = (1 + Math.sqrt(5)) / 2;
43
+ const ICOSAHEDRON_SCALE = 1 / Math.sqrt(1 + PHI * PHI);
44
+ const ICOSAHEDRON_VERTICES = [
45
+ [-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0],
46
+ [0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI],
47
+ [PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1]
48
+ ].map(([x, y, z]) => [x * ICOSAHEDRON_SCALE, y * ICOSAHEDRON_SCALE, z * ICOSAHEDRON_SCALE]);
49
+
50
+ const ICOSAHEDRON_EDGES = [
51
+ [0, 1], [0, 5], [0, 7], [0, 10], [0, 11],
52
+ [1, 5], [1, 7], [1, 8], [1, 9],
53
+ [2, 3], [2, 4], [2, 6], [2, 10], [2, 11],
54
+ [3, 4], [3, 6], [3, 8], [3, 9],
55
+ [4, 5], [4, 9], [4, 11],
56
+ [5, 9], [5, 11],
57
+ [6, 7], [6, 8], [6, 10],
58
+ [7, 8], [7, 10],
59
+ [8, 9],
60
+ [10, 11]
61
+ ];
62
+
63
+ const MESH_PRESETS = {
64
+ cube: { vertices: BASE_VERTICES.map(([x, y, z]) => [x * 0.62, y * 0.62, z * 0.62]), edges: EDGES },
65
+ tetrahedron: { vertices: TETRAHEDRON_VERTICES, edges: TETRAHEDRON_EDGES },
66
+ octahedron: { vertices: OCTAHEDRON_VERTICES, edges: OCTAHEDRON_EDGES },
67
+ icosahedron: { vertices: ICOSAHEDRON_VERTICES, edges: ICOSAHEDRON_EDGES }
68
+ };
69
+
18
70
  export const buildWireframeLines = ({ rotationY, rotationX, deform }) => {
19
71
  const amount = clamp(Number(deform || 0), 0, 1);
20
72
  const projected = BASE_VERTICES.map((vertex) => {
@@ -34,6 +86,34 @@ export const buildWireframeLines = ({ rotationY, rotationX, deform }) => {
34
86
  return lines;
35
87
  };
36
88
 
89
+ export const buildPresetMeshLines = ({ rotationY = 0, rotationX = 0, deform = 0, params = {} } = {}) => {
90
+ const geometry = normalizeMeshGeometry(params.geometry);
91
+ const mesh = MESH_PRESETS[geometry] || MESH_PRESETS.icosahedron;
92
+ const scale = clamp(finiteNumber(params.scale, 1), 0.1, 3.0);
93
+ const amount = clamp(finiteNumber(deform, 0), 0, 1);
94
+
95
+ const projected = mesh.vertices.map((vertex, index) => {
96
+ const radialPulse = 1 + amount * (0.12 + (index % 3) * 0.05);
97
+ const twist = Math.sin(index * 1.618 + amount * Math.PI) * amount * 0.08;
98
+ return projectVertex(
99
+ [
100
+ (vertex[0] + vertex[1] * twist) * scale * radialPulse,
101
+ (vertex[1] + vertex[2] * twist) * scale * (1 + amount * 0.08),
102
+ (vertex[2] + vertex[0] * twist) * scale * radialPulse
103
+ ],
104
+ rotationY,
105
+ rotationX
106
+ );
107
+ });
108
+
109
+ const lines = [];
110
+ for (const [start, end] of mesh.edges) {
111
+ lines.push(projected[start][0], projected[start][1]);
112
+ lines.push(projected[end][0], projected[end][1]);
113
+ }
114
+ return lines;
115
+ };
116
+
37
117
  export const estimateDeformFromSpectrum = (value) => {
38
118
  if (Array.isArray(value)) {
39
119
  if (value.length === 0) {
@@ -46,6 +126,183 @@ export const estimateDeformFromSpectrum = (value) => {
46
126
  return clamp(Number(value || 0), 0, 1);
47
127
  };
48
128
 
129
+ export const buildRadialBlobLines = ({ time, params = {}, audio = {} }) => {
130
+ const segments = clampInt(params.segments || 160, 24, 512);
131
+ const baseRadius = clamp(Number(params.radius ?? 0.46), 0.05, 1.4);
132
+ const wobble = clamp(Number(params.wobble ?? audio?.amplitude ?? 0), 0, 3);
133
+ const spectrum = Array.isArray(params.spectrum) ? params.spectrum : Array.isArray(audio?.fft) ? audio.fft : [];
134
+ const bass = clamp(Number(audio?.bands?.low || 0), 0, 1);
135
+ const mid = clamp(Number(audio?.bands?.mid || 0), 0, 1);
136
+ const pulse = clamp(Number(audio?.beat_pulse || (audio?.beat ? 1 : 0)), 0, 1);
137
+ const points = [];
138
+
139
+ const sample = (index) => {
140
+ if (!spectrum.length) return 0;
141
+ return clamp(Number(spectrum[index % spectrum.length] || 0), 0, 1);
142
+ };
143
+
144
+ for (let index = 0; index < segments; index += 1) {
145
+ const next = (index + 1) % segments;
146
+ appendRadialPoint(points, index, segments, baseRadius, wobble, bass, mid, pulse, time, sample(index));
147
+ appendRadialPoint(points, next, segments, baseRadius, wobble, bass, mid, pulse, time, sample(next));
148
+ }
149
+
150
+ return points;
151
+ };
152
+
153
+ export const normalizeWaveformStyle = (value) => {
154
+ const style = String(value || "line").trim().toLowerCase();
155
+ if (style === "mirror" || style === "ribbon") return style;
156
+ return "line";
157
+ };
158
+
159
+ export const buildWaveformLines = ({ time = 0, params = {}, audio = {} } = {}) => {
160
+ const detail = clampInt(params.detail || 96, 16, 256);
161
+ const height = clamp(finiteNumber(params.height ?? 0.46, 0.46), 0.05, 1.1);
162
+ const amplitude = clamp(finiteNumber(audio?.amplitude, 0), 0, 1);
163
+ const spectrum = Array.isArray(params.spectrum) ? params.spectrum : Array.isArray(audio?.fft) ? audio.fft : [];
164
+ const style = normalizeWaveformStyle(params.style);
165
+ const samples = buildWaveformSamples({ detail, height, amplitude, spectrum, time });
166
+ const points = [];
167
+
168
+ appendLineSegments(points, samples);
169
+
170
+ if (style === "mirror" || style === "ribbon") {
171
+ const mirrored = samples.map(([x, y]) => [x, -y]);
172
+ appendLineSegments(points, mirrored);
173
+ }
174
+
175
+ if (style === "ribbon") {
176
+ const stride = Math.max(4, Math.round(detail / 16));
177
+ for (let index = 0; index < samples.length; index += stride) {
178
+ const [x, y] = samples[index];
179
+ points.push(x, y, x, -y);
180
+ }
181
+ }
182
+
183
+ return points;
184
+ };
185
+
186
+ export const buildShapeLines = ({ params = {} } = {}) => {
187
+ const shapes = Array.isArray(params.shapes) ? params.shapes : [];
188
+ const points = [];
189
+
190
+ shapes.forEach((shape) => {
191
+ const kind = String(shape?.kind || shape?.type || "").toLowerCase();
192
+ if (kind === "circle") {
193
+ appendCircleShape(points, shape);
194
+ } else if (kind === "line") {
195
+ appendLineShape(points, shape);
196
+ }
197
+ });
198
+
199
+ return points;
200
+ };
201
+
202
+ const buildWaveformSamples = ({ detail, height, amplitude, spectrum, time }) => {
203
+ const samples = [];
204
+ const safeTime = finiteNumber(time, 0);
205
+
206
+ for (let index = 0; index < detail; index += 1) {
207
+ const progress = detail === 1 ? 0 : index / (detail - 1);
208
+ const x = -0.92 + progress * 1.84;
209
+ const fftValue = sampleSpectrum(spectrum, progress);
210
+ const carrier = Math.sin(index * 0.55 + safeTime * (2.4 + amplitude * 2.0));
211
+ const harmonic = Math.sin(index * 0.13 + safeTime * 1.1);
212
+ const energy = 0.12 + amplitude * 0.35 + fftValue * 0.55;
213
+ const y = clamp((carrier * 0.72 + harmonic * 0.28) * energy * height, -0.92, 0.92);
214
+ samples.push([x, y]);
215
+ }
216
+
217
+ return samples;
218
+ };
219
+
220
+ const appendLineSegments = (points, samples) => {
221
+ for (let index = 1; index < samples.length; index += 1) {
222
+ points.push(samples[index - 1][0], samples[index - 1][1], samples[index][0], samples[index][1]);
223
+ }
224
+ };
225
+
226
+ const appendCircleShape = (points, shape) => {
227
+ const count = clampInt(shape.count || 1, 1, 64);
228
+ const segments = clampInt(shape.segments || 96, 12, 256);
229
+ const radius = normalizeShapeRadius(shape.radius ?? 100);
230
+ const x = normalizeShapeCoordinate(shape.x ?? 0, "x");
231
+ const y = normalizeShapeCoordinate(shape.y ?? 0, "y");
232
+
233
+ for (let ring = 0; ring < count; ring += 1) {
234
+ const ringRadius = radius * ((ring + 1) / count);
235
+ for (let index = 0; index < segments; index += 1) {
236
+ appendCirclePoint(points, x, y, ringRadius, index, segments);
237
+ appendCirclePoint(points, x, y, ringRadius, index + 1, segments);
238
+ }
239
+ }
240
+ };
241
+
242
+ const appendCirclePoint = (points, x, y, radius, index, segments) => {
243
+ const angle = (index / segments) * Math.PI * 2;
244
+ points.push(
245
+ clamp(x + Math.cos(angle) * radius, -1.2, 1.2),
246
+ clamp(y + Math.sin(angle) * radius, -1.2, 1.2)
247
+ );
248
+ };
249
+
250
+ const appendLineShape = (points, shape) => {
251
+ points.push(
252
+ normalizeShapeCoordinate(shape.x1 ?? -0.8, "x"),
253
+ normalizeShapeCoordinate(shape.y1 ?? 0, "y"),
254
+ normalizeShapeCoordinate(shape.x2 ?? 0.8, "x"),
255
+ normalizeShapeCoordinate(shape.y2 ?? 0, "y")
256
+ );
257
+ };
258
+
259
+ const normalizeShapeRadius = (value) => {
260
+ const numeric = finiteNumber(value, 100);
261
+ const radius = Math.abs(numeric) <= 2 ? Math.abs(numeric) : Math.abs(numeric) / 360;
262
+ return clamp(radius, 0.005, 1.4);
263
+ };
264
+
265
+ const normalizeShapeCoordinate = (value, axis) => {
266
+ const numeric = finiteNumber(value, 0);
267
+ if (Math.abs(numeric) <= 1.5) {
268
+ return clamp(numeric, -1.2, 1.2);
269
+ }
270
+
271
+ if (axis === "y") {
272
+ return clamp(1 - numeric / 360, -1.2, 1.2);
273
+ }
274
+
275
+ return clamp(numeric / 640 - 1, -1.2, 1.2);
276
+ };
277
+
278
+ const sampleSpectrum = (spectrum, progress) => {
279
+ if (!spectrum.length) return 0;
280
+
281
+ const position = progress * (spectrum.length - 1);
282
+ const left = Math.floor(position);
283
+ const right = Math.min(left + 1, spectrum.length - 1);
284
+ const mix = position - left;
285
+ const from = finiteNumber(spectrum[left], 0);
286
+ const to = finiteNumber(spectrum[right], 0);
287
+ return clamp(from + (to - from) * mix, 0, 1);
288
+ };
289
+
290
+ const appendRadialPoint = (points, index, segments, baseRadius, wobble, bass, mid, pulse, time, fftValue) => {
291
+ const angle = (index / segments) * Math.PI * 2;
292
+ const organic = Math.sin(angle * (3.0 + mid * 5.0) + time * (1.2 + bass * 2.0));
293
+ const radius = baseRadius
294
+ + bass * 0.14
295
+ + pulse * 0.10
296
+ + fftValue * (0.10 + wobble * 0.12)
297
+ + organic * wobble * 0.035;
298
+
299
+ points.push(Math.cos(angle) * radius, Math.sin(angle) * radius);
300
+ };
301
+
302
+ const normalizeMeshGeometry = (value) => {
303
+ return String(value || "icosahedron").trim().toLowerCase();
304
+ };
305
+
49
306
  const projectVertex = (vertex, angleY, angleX) => {
50
307
  const [x, y, z] = vertex;
51
308
 
@@ -63,4 +320,15 @@ const projectVertex = (vertex, angleY, angleX) => {
63
320
  return [x1 * perspectiveScale, y1 * perspectiveScale];
64
321
  };
65
322
 
323
+ const clampInt = (value, min, max) => {
324
+ const numeric = Number(value);
325
+ if (!Number.isFinite(numeric)) return min;
326
+ return Math.round(Math.min(Math.max(numeric, min), max));
327
+ };
328
+
329
+ const finiteNumber = (value, fallback) => {
330
+ const numeric = Number(value);
331
+ return Number.isFinite(numeric) ? numeric : fallback;
332
+ };
333
+
66
334
  const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
@@ -0,0 +1,291 @@
1
+ const IMAGE_VERTEX_SHADER = `#version 300 es
2
+ in vec2 a_position;
3
+ in vec2 a_uv;
4
+ out vec2 v_uv;
5
+ void main() {
6
+ v_uv = a_uv;
7
+ gl_Position = vec4(a_position, 0.0, 1.0);
8
+ }
9
+ `;
10
+
11
+ const IMAGE_FRAGMENT_SHADER = `#version 300 es
12
+ precision mediump float;
13
+ in vec2 v_uv;
14
+ uniform sampler2D u_texture;
15
+ uniform float u_intensity;
16
+ uniform float u_invert;
17
+ out vec4 outColor;
18
+
19
+ void main() {
20
+ vec4 texel = texture(u_texture, v_uv);
21
+ vec3 color = mix(texel.rgb, vec3(1.0) - texel.rgb, clamp(u_invert, 0.0, 1.0));
22
+ outColor = vec4(color, texel.a * u_intensity);
23
+ }
24
+ `;
25
+
26
+ const QUAD_VERTICES = new Float32Array([
27
+ -1.0, -1.0, 0.0, 1.0,
28
+ 1.0, -1.0, 1.0, 1.0,
29
+ -1.0, 1.0, 0.0, 0.0,
30
+ 1.0, 1.0, 1.0, 0.0
31
+ ]);
32
+
33
+ export class ImageRenderer {
34
+ constructor(gl, shaderManager) {
35
+ this.gl = gl;
36
+ this.shaderManager = shaderManager;
37
+ this.program = this.shaderManager.getProgram("image-renderer", IMAGE_VERTEX_SHADER, IMAGE_FRAGMENT_SHADER);
38
+ this.positionLocation = this.gl.getAttribLocation(this.program, "a_position");
39
+ this.uvLocation = this.gl.getAttribLocation(this.program, "a_uv");
40
+ this.textureLocation = this.gl.getUniformLocation(this.program, "u_texture");
41
+ this.intensityLocation = this.gl.getUniformLocation(this.program, "u_intensity");
42
+ this.invertLocation = this.gl.getUniformLocation(this.program, "u_invert");
43
+ this.media = new Map();
44
+
45
+ this.buffer = this.gl.createBuffer();
46
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
47
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, QUAD_VERTICES, this.gl.STATIC_DRAW);
48
+
49
+ this.canvas = document.createElement("canvas");
50
+ this.canvas.width = 1024;
51
+ this.canvas.height = 1024;
52
+ this.ctx = this.canvas.getContext("2d");
53
+
54
+ this.texture = this.gl.createTexture();
55
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
56
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR);
57
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR);
58
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE);
59
+ this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE);
60
+ }
61
+
62
+ render({ src, audio, fit, scale, rotation, playbackRate, invert }) {
63
+ const source = resolveMediaSource(src);
64
+ if (!source) return;
65
+
66
+ const media = this.loadMedia(source);
67
+ this.ensureVideoPlayback(media, playbackRate);
68
+ const dimensions = resolveMediaDimensions(media);
69
+ if (!isRenderableMedia(media, dimensions)) {
70
+ return;
71
+ }
72
+
73
+ this.syncCanvasSize();
74
+ const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
75
+ const pulse = clamp(Number(audio?.beat_pulse || 0), 0, 1);
76
+ this.drawImageToCanvas({
77
+ media,
78
+ dimensions,
79
+ fit,
80
+ scale: normalizeScale(scale) * (1 + amplitude * 0.04 + pulse * 0.03),
81
+ rotation: normalizeRotation(rotation)
82
+ });
83
+ this.uploadTexture();
84
+ this.drawQuad({ intensity: 0.9 + amplitude * 0.1, invert: normalizeInvert(invert) });
85
+ }
86
+
87
+ loadMedia(src) {
88
+ return isVideoSource(src) ? this.loadVideo(src) : this.loadImage(src);
89
+ }
90
+
91
+ loadImage(src) {
92
+ if (this.media.has(src)) {
93
+ return this.media.get(src);
94
+ }
95
+
96
+ const image = new Image();
97
+ if (!src.startsWith("data:")) {
98
+ image.crossOrigin = "anonymous";
99
+ }
100
+ image.decoding = "async";
101
+ image.src = src;
102
+ this.media.set(src, image);
103
+ return image;
104
+ }
105
+
106
+ loadVideo(src) {
107
+ if (this.media.has(src)) {
108
+ return this.media.get(src);
109
+ }
110
+
111
+ const video = document.createElement("video");
112
+ if (!src.startsWith("data:")) {
113
+ video.crossOrigin = "anonymous";
114
+ }
115
+ video.muted = true;
116
+ video.loop = true;
117
+ video.playsInline = true;
118
+ video.preload = "auto";
119
+ video.src = src;
120
+ this.media.set(src, video);
121
+ return video;
122
+ }
123
+
124
+ ensureVideoPlayback(media, playbackRate) {
125
+ if (!isVideoElement(media)) {
126
+ return;
127
+ }
128
+
129
+ const rate = normalizePlaybackRate(playbackRate);
130
+ if (media.playbackRate !== rate) {
131
+ media.playbackRate = rate;
132
+ }
133
+
134
+ if (!media.paused) {
135
+ return;
136
+ }
137
+
138
+ const playback = media.play();
139
+ if (playback?.catch) {
140
+ playback.catch(() => {});
141
+ }
142
+ }
143
+
144
+ drawImageToCanvas({ media, dimensions, fit, scale, rotation }) {
145
+ const ctx = this.ctx;
146
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
147
+ const rect = resolveImageRect({
148
+ canvasWidth: this.canvas.width,
149
+ canvasHeight: this.canvas.height,
150
+ imageWidth: dimensions.width,
151
+ imageHeight: dimensions.height,
152
+ fit,
153
+ scale
154
+ });
155
+
156
+ ctx.save();
157
+ ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
158
+ ctx.rotate(rotation);
159
+ ctx.drawImage(media, -rect.width / 2, -rect.height / 2, rect.width, rect.height);
160
+ ctx.restore();
161
+ }
162
+
163
+ syncCanvasSize() {
164
+ const width = clamp(Math.floor(this.gl.drawingBufferWidth || 1024), 640, 2048);
165
+ const height = clamp(Math.floor(this.gl.drawingBufferHeight || 1024), 360, 2048);
166
+ if (this.canvas.width === width && this.canvas.height === height) {
167
+ return;
168
+ }
169
+ this.canvas.width = width;
170
+ this.canvas.height = height;
171
+ }
172
+
173
+ uploadTexture() {
174
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
175
+ this.gl.texImage2D(
176
+ this.gl.TEXTURE_2D,
177
+ 0,
178
+ this.gl.RGBA,
179
+ this.gl.RGBA,
180
+ this.gl.UNSIGNED_BYTE,
181
+ this.canvas
182
+ );
183
+ }
184
+
185
+ drawQuad({ intensity, invert }) {
186
+ const gl = this.gl;
187
+ gl.useProgram(this.program);
188
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
189
+ gl.enableVertexAttribArray(this.positionLocation);
190
+ gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 16, 0);
191
+ gl.enableVertexAttribArray(this.uvLocation);
192
+ gl.vertexAttribPointer(this.uvLocation, 2, gl.FLOAT, false, 16, 8);
193
+
194
+ gl.activeTexture(gl.TEXTURE0);
195
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
196
+ gl.uniform1i(this.textureLocation, 0);
197
+ gl.uniform1f(this.intensityLocation, clamp(Number(intensity || 1), 0, 1));
198
+ gl.uniform1f(this.invertLocation, normalizeInvert(invert));
199
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
200
+ }
201
+ }
202
+
203
+ export const resolveMediaSource = (value) => {
204
+ const source = String(value || "").trim();
205
+ return source || null;
206
+ };
207
+
208
+ export const normalizeImageFit = (value) => {
209
+ const fit = String(value || "contain").trim().toLowerCase();
210
+ if (fit === "cover" || fit === "stretch") return fit;
211
+ return "contain";
212
+ };
213
+
214
+ export const normalizeScale = (value) => {
215
+ const scale = Number(value);
216
+ if (!Number.isFinite(scale)) return 1;
217
+ return clamp(scale, 0.01, 8);
218
+ };
219
+
220
+ export const normalizeRotation = (value) => {
221
+ const rotation = Number(value);
222
+ return Number.isFinite(rotation) ? rotation : 0;
223
+ };
224
+
225
+ export const normalizePlaybackRate = (value) => {
226
+ const rate = Number(value);
227
+ if (!Number.isFinite(rate)) return 1;
228
+ return clamp(rate, 0.1, 4);
229
+ };
230
+
231
+ export const normalizeInvert = (value) => {
232
+ const amount = Number(value);
233
+ if (!Number.isFinite(amount)) return 0;
234
+ return clamp(amount, 0, 1);
235
+ };
236
+
237
+ export const isVideoSource = (value) => {
238
+ const source = resolveMediaSource(value);
239
+ if (!source) return false;
240
+ if (/^data:video\//i.test(source)) return true;
241
+ return /\.(mp4|webm|ogv|ogg)(?:[?#].*)?$/i.test(source);
242
+ };
243
+
244
+ export const resolveMediaDimensions = (media) => {
245
+ if (isVideoElement(media)) {
246
+ return {
247
+ width: Number(media.videoWidth || 0),
248
+ height: Number(media.videoHeight || 0)
249
+ };
250
+ }
251
+
252
+ return {
253
+ width: Number(media?.naturalWidth || 0),
254
+ height: Number(media?.naturalHeight || 0)
255
+ };
256
+ };
257
+
258
+ export const resolveImageRect = ({ canvasWidth, canvasHeight, imageWidth, imageHeight, fit, scale = 1 }) => {
259
+ const width = Math.max(Number(canvasWidth) || 0, 1);
260
+ const height = Math.max(Number(canvasHeight) || 0, 1);
261
+ const sourceWidth = Math.max(Number(imageWidth) || 0, 1);
262
+ const sourceHeight = Math.max(Number(imageHeight) || 0, 1);
263
+ const resolvedScale = normalizeScale(scale);
264
+ const resolvedFit = normalizeImageFit(fit);
265
+
266
+ if (resolvedFit === "stretch") {
267
+ return { width: width * resolvedScale, height: height * resolvedScale };
268
+ }
269
+
270
+ const multiplier = resolvedFit === "cover"
271
+ ? Math.max(width / sourceWidth, height / sourceHeight)
272
+ : Math.min(width / sourceWidth, height / sourceHeight);
273
+ return {
274
+ width: sourceWidth * multiplier * resolvedScale,
275
+ height: sourceHeight * multiplier * resolvedScale
276
+ };
277
+ };
278
+
279
+ const isRenderableMedia = (media, dimensions) => {
280
+ if (isVideoElement(media)) {
281
+ return media.readyState >= 2 && dimensions.width > 0 && dimensions.height > 0;
282
+ }
283
+
284
+ return !!media?.complete && dimensions.width > 0 && dimensions.height > 0;
285
+ };
286
+
287
+ const isVideoElement = (media) => {
288
+ return media?.tagName === "VIDEO" || (typeof media?.play === "function" && "videoWidth" in media);
289
+ };
290
+
291
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);