vizcore 0.1.0 → 1.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. data/docs/GETTING_STARTED.md +0 -105
@@ -0,0 +1,109 @@
1
+ const SHAPE_TARGETS = {
2
+ circle: ["x", "y", "radius", "segments", "count"],
3
+ line: ["x1", "y1", "x2", "y2"],
4
+ rect: ["x", "y", "width", "height", "radius"],
5
+ polygon: [],
6
+ polyline: [],
7
+ path: ["detail", "tolerance", "max_segments"],
8
+ star: ["x", "y", "points", "radius", "inner_radius", "rotation"],
9
+ };
10
+
11
+ const COMMON_SHAPE_TARGETS = [
12
+ "opacity",
13
+ "stroke_width",
14
+ "transform.translate.x",
15
+ "transform.translate.y",
16
+ "transform.rotate",
17
+ "transform.scale",
18
+ "transform.scale.x",
19
+ "transform.scale.y",
20
+ "transform.origin.x",
21
+ "transform.origin.y",
22
+ ];
23
+
24
+ export const mappingTargetOptions = (layers) => {
25
+ const options = [];
26
+ const seen = new Set();
27
+ const layerList = Array.isArray(layers) ? layers : [];
28
+
29
+ layerList.forEach((layer, layerIndex) => {
30
+ const layerName = String(layer?.name || `layer_${layerIndex + 1}`);
31
+ appendLayerParamTargets(options, seen, layer, layerName);
32
+ appendShapeTargets(options, seen, layer, layerName);
33
+ appendCustomShapeTargets(options, seen, layer, layerName);
34
+ });
35
+
36
+ return options;
37
+ };
38
+
39
+ export const mappingTargetSignature = (options) => (
40
+ (Array.isArray(options) ? options : []).map((option) => `${option.layerName}:${option.target}`).join("|")
41
+ );
42
+
43
+ const appendLayerParamTargets = (options, seen, layer, layerName) => {
44
+ const schema = Array.isArray(layer?.param_schema) ? layer.param_schema : [];
45
+ schema.forEach((entry) => {
46
+ const paramName = normalizeName(entry?.name);
47
+ if (!paramName) return;
48
+
49
+ appendOption(options, seen, {
50
+ layerName,
51
+ target: paramName,
52
+ label: `${layerName}.${paramName}`,
53
+ scope: "layer",
54
+ });
55
+ });
56
+ };
57
+
58
+ const appendShapeTargets = (options, seen, layer, layerName) => {
59
+ const shapes = Array.isArray(layer?.params?.shapes) ? layer.params.shapes : [];
60
+ shapes.forEach((shape, shapeIndex) => {
61
+ const kind = String(shape?.kind || shape?.type || "").toLowerCase();
62
+ const shapeName = normalizeName(shape?.id) || `shape_${shapeIndex + 1}`;
63
+ const targets = [...COMMON_SHAPE_TARGETS, ...(SHAPE_TARGETS[kind] || [])];
64
+ targets.forEach((target) => {
65
+ appendOption(options, seen, {
66
+ layerName,
67
+ target: `shapes.${shapeIndex}.${target}`,
68
+ label: `${layerName}.${shapeName}.${target}`,
69
+ scope: "shape",
70
+ });
71
+ });
72
+ });
73
+ };
74
+
75
+ const appendCustomShapeTargets = (options, seen, layer, layerName) => {
76
+ const controls = Array.isArray(layer?.params?.custom_shape_controls) ? layer.params.custom_shape_controls : [];
77
+ controls.forEach((control, fallbackIndex) => {
78
+ const index = finiteIndex(control?.index, fallbackIndex);
79
+ const customShapeName = normalizeName(control?.name) || `custom_shape_${index + 1}`;
80
+ const params = control?.params && typeof control.params === "object" ? Object.keys(control.params) : [];
81
+ const schemaNames = (Array.isArray(control?.param_schema) ? control.param_schema : []).map((entry) => normalizeName(entry?.name)).filter(Boolean);
82
+ [...new Set([...schemaNames, ...params])].sort().forEach((paramName) => {
83
+ appendOption(options, seen, {
84
+ layerName,
85
+ target: `custom_shapes.${index}.params.${paramName}`,
86
+ label: `${layerName}.${customShapeName}.${paramName}`,
87
+ scope: "custom_shape",
88
+ });
89
+ });
90
+ });
91
+ };
92
+
93
+ const appendOption = (options, seen, option) => {
94
+ const key = `${option.layerName}:${option.target}`;
95
+ if (seen.has(key)) return;
96
+
97
+ seen.add(key);
98
+ options.push({ ...option, key });
99
+ };
100
+
101
+ const normalizeName = (value) => {
102
+ const name = String(value || "").trim();
103
+ return name || null;
104
+ };
105
+
106
+ const finiteIndex = (value, fallback) => {
107
+ const numeric = Number(value);
108
+ return Number.isInteger(numeric) && numeric >= 0 ? numeric : fallback;
109
+ };
@@ -0,0 +1,194 @@
1
+ export const MIDI_LEARN_BINDINGS_KEY = "vizcore.midiLearnBindings.v1";
2
+
3
+ const ACTION_TYPES = new Set(["switch_scene", "live_control", "visual_setting"]);
4
+ const LIVE_CONTROLS = new Set(["blackout", "freeze"]);
5
+ const VISUAL_SETTING_KEYS = new Set(["visualGain", "bassBoost", "smoothing", "beatHoldMs", "wobbleAmount"]);
6
+
7
+ export const loadMidiLearnBindings = (storage, {
8
+ key = MIDI_LEARN_BINDINGS_KEY,
9
+ } = {}) => {
10
+ if (!storage) {
11
+ return {};
12
+ }
13
+
14
+ try {
15
+ return normalizeMidiLearnBindings(JSON.parse(storage.getItem(key) || "{}"));
16
+ } catch {
17
+ return {};
18
+ }
19
+ };
20
+
21
+ export const saveMidiLearnBindings = (storage, bindings, {
22
+ key = MIDI_LEARN_BINDINGS_KEY,
23
+ } = {}) => {
24
+ const normalized = normalizeMidiLearnBindings(bindings);
25
+ if (!storage) {
26
+ return normalized;
27
+ }
28
+
29
+ try {
30
+ storage.setItem(key, JSON.stringify(normalized));
31
+ } catch {
32
+ // Ignore storage failures; learned bindings still work for this session.
33
+ }
34
+ return normalized;
35
+ };
36
+
37
+ export const normalizeMidiLearnBindings = (bindings) => {
38
+ const input = bindings && typeof bindings === "object" ? bindings : {};
39
+ const normalized = {};
40
+
41
+ for (const [signature, action] of Object.entries(input)) {
42
+ const safeSignature = normalizeMidiSignature(signature);
43
+ const safeAction = normalizeMidiAction(action);
44
+ if (!safeSignature || !safeAction) {
45
+ continue;
46
+ }
47
+ normalized[safeSignature] = safeAction;
48
+ }
49
+
50
+ return normalized;
51
+ };
52
+
53
+ export const upsertMidiLearnBinding = (bindings, signature, action) => {
54
+ const safeSignature = normalizeMidiSignature(signature);
55
+ const safeAction = normalizeMidiAction(action);
56
+ const next = normalizeMidiLearnBindings(bindings);
57
+
58
+ if (safeSignature && safeAction) {
59
+ next[safeSignature] = safeAction;
60
+ }
61
+
62
+ return next;
63
+ };
64
+
65
+ export const midiMessageSignature = (data) => {
66
+ const bytes = normalizeMidiBytes(data);
67
+ if (!bytes.length) {
68
+ return null;
69
+ }
70
+
71
+ const status = bytes[0];
72
+ const command = status & 0xf0;
73
+ const channel = (status & 0x0f) + 1;
74
+ if (command === 0x80 || command === 0x90) {
75
+ return `note:${channel}:${bytes[1] ?? 0}`;
76
+ }
77
+ if (command === 0xb0) {
78
+ return `cc:${channel}:${bytes[1] ?? 0}`;
79
+ }
80
+ if (command === 0xc0) {
81
+ return `pc:${channel}:${bytes[1] ?? 0}`;
82
+ }
83
+ return `raw:${channel}:${status}:${bytes[1] ?? 0}`;
84
+ };
85
+
86
+ export const midiMessageActive = (data) => {
87
+ const bytes = normalizeMidiBytes(data);
88
+ if (!bytes.length) {
89
+ return false;
90
+ }
91
+
92
+ const command = bytes[0] & 0xf0;
93
+ if (command === 0x80) {
94
+ return false;
95
+ }
96
+ if (command === 0x90) {
97
+ return Number(bytes[2] || 0) > 0;
98
+ }
99
+ return true;
100
+ };
101
+
102
+ export const midiMessageUnitValue = (data) => {
103
+ const bytes = normalizeMidiBytes(data);
104
+ if (!bytes.length) {
105
+ return 0;
106
+ }
107
+
108
+ const command = bytes[0] & 0xf0;
109
+ if (command === 0xb0 || command === 0x90 || command === 0x80) {
110
+ return clampUnit(Number(bytes[2] || 0) / 127);
111
+ }
112
+ if (command === 0xc0) {
113
+ return clampUnit(Number(bytes[1] || 0) / 127);
114
+ }
115
+ return 1;
116
+ };
117
+
118
+ export const midiLearnActionLabel = (action) => {
119
+ const safeAction = normalizeMidiAction(action);
120
+ if (!safeAction) {
121
+ return "Unknown";
122
+ }
123
+
124
+ if (safeAction.type === "switch_scene") {
125
+ return `Switch scene: ${safeAction.scene}`;
126
+ }
127
+ if (safeAction.type === "live_control") {
128
+ return `Toggle ${safeAction.control}`;
129
+ }
130
+ return `Control ${visualSettingLabel(safeAction.key)}`;
131
+ };
132
+
133
+ export const midiSignatureLabel = (signature) => {
134
+ const [kind, channel, value] = String(signature || "").split(":");
135
+ if (kind === "note") return `Note ${value} ch ${channel}`;
136
+ if (kind === "cc") return `CC ${value} ch ${channel}`;
137
+ if (kind === "pc") return `Program ${value} ch ${channel}`;
138
+ return signature || "unknown";
139
+ };
140
+
141
+ const normalizeMidiAction = (action) => {
142
+ if (!action || typeof action !== "object") {
143
+ return null;
144
+ }
145
+
146
+ const type = String(action.type || "");
147
+ if (!ACTION_TYPES.has(type)) {
148
+ return null;
149
+ }
150
+
151
+ if (type === "switch_scene") {
152
+ const scene = String(action.scene || "").trim();
153
+ return scene ? { type, scene } : null;
154
+ }
155
+
156
+ if (type === "live_control") {
157
+ const control = String(action.control || "").trim();
158
+ return LIVE_CONTROLS.has(control) ? { type, control } : null;
159
+ }
160
+
161
+ const key = String(action.key || "").trim();
162
+ return VISUAL_SETTING_KEYS.has(key) ? { type, key } : null;
163
+ };
164
+
165
+ const normalizeMidiSignature = (signature) => {
166
+ const value = String(signature || "").trim().toLowerCase();
167
+ return value.includes(":") ? value : null;
168
+ };
169
+
170
+ const normalizeMidiBytes = (data) => {
171
+ if (Array.isArray(data) || ArrayBuffer.isView(data)) {
172
+ return Array.from(data).map((value) => Number(value) || 0);
173
+ }
174
+ return [];
175
+ };
176
+
177
+ const visualSettingLabel = (key) => {
178
+ switch (key) {
179
+ case "visualGain":
180
+ return "Visual Gain";
181
+ case "bassBoost":
182
+ return "Bass Boost";
183
+ case "smoothing":
184
+ return "Smoothing";
185
+ case "beatHoldMs":
186
+ return "Beat Hold";
187
+ case "wobbleAmount":
188
+ return "Wobble";
189
+ default:
190
+ return key;
191
+ }
192
+ };
193
+
194
+ const clampUnit = (value) => Math.min(Math.max(Number(value) || 0, 0), 1);
@@ -0,0 +1,183 @@
1
+ const DEFAULT_EXPECTED_FRAME_MS = 1000 / 60;
2
+ const FPS_WINDOW_MS = 500;
3
+
4
+ export const createPerformanceMonitorState = () => ({
5
+ audioLatencyMs: null,
6
+ clockOffsetMs: null,
7
+ droppedFrames: 0,
8
+ fps: 0,
9
+ frameMs: 0,
10
+ lastRenderAtMs: null,
11
+ lastSocketTimestampMs: null,
12
+ reconnects: 0,
13
+ renderWindowFrames: 0,
14
+ renderWindowStartedAtMs: null,
15
+ rttMs: null,
16
+ shaderCompileMs: null,
17
+ wsLatencyMs: null,
18
+ });
19
+
20
+ export const recordRenderFrame = (state, nowMs) => {
21
+ const now = Number(nowMs);
22
+ if (!Number.isFinite(now)) {
23
+ return { ...state };
24
+ }
25
+
26
+ const previousRenderAt = Number.isFinite(state?.lastRenderAtMs) ? state.lastRenderAtMs : null;
27
+ const frameMs = previousRenderAt === null ? 0 : Math.max(0, now - previousRenderAt);
28
+ const windowStartedAt = Number.isFinite(state?.renderWindowStartedAtMs)
29
+ ? state.renderWindowStartedAtMs
30
+ : now;
31
+ const windowFrames = Number(state?.renderWindowFrames || 0) + 1;
32
+ const windowMs = now - windowStartedAt;
33
+
34
+ if (windowMs >= FPS_WINDOW_MS) {
35
+ return {
36
+ ...state,
37
+ fps: roundOneDecimal((windowFrames * 1000) / windowMs),
38
+ frameMs: roundOneDecimal(frameMs),
39
+ lastRenderAtMs: now,
40
+ renderWindowFrames: 0,
41
+ renderWindowStartedAtMs: now,
42
+ };
43
+ }
44
+
45
+ return {
46
+ ...state,
47
+ frameMs: roundOneDecimal(frameMs),
48
+ lastRenderAtMs: now,
49
+ renderWindowFrames: windowFrames,
50
+ renderWindowStartedAtMs: windowStartedAt,
51
+ };
52
+ };
53
+
54
+ export const recordSocketFrame = (
55
+ state,
56
+ frame,
57
+ receivedAtMs,
58
+ expectedFrameMs = DEFAULT_EXPECTED_FRAME_MS,
59
+ ) => {
60
+ const timestampMs = Number(frame?.timestamp) * 1000;
61
+ const receivedAt = Number(receivedAtMs);
62
+ if (!Number.isFinite(timestampMs) || timestampMs <= 0 || !Number.isFinite(receivedAt)) {
63
+ return { ...state };
64
+ }
65
+
66
+ const previousTimestamp = Number.isFinite(state?.lastSocketTimestampMs)
67
+ ? state.lastSocketTimestampMs
68
+ : null;
69
+ const frameGapMs = previousTimestamp === null ? 0 : Math.max(0, timestampMs - previousTimestamp);
70
+ const droppedFrames = Number(state?.droppedFrames || 0) + estimateDroppedFrames(frameGapMs, expectedFrameMs);
71
+ const audioLatencyMs = audioLatencyFromMetrics(frame?.metrics, state?.audioLatencyMs);
72
+ const clockOffsetMs = Number.isFinite(state?.clockOffsetMs) ? Number(state.clockOffsetMs) : 0;
73
+ const browserTimestampMs = timestampMs - clockOffsetMs;
74
+
75
+ return {
76
+ ...state,
77
+ audioLatencyMs,
78
+ droppedFrames,
79
+ lastSocketTimestampMs: timestampMs,
80
+ wsLatencyMs: Math.max(0, Math.round(receivedAt - browserTimestampMs)),
81
+ };
82
+ };
83
+
84
+ export const recordLatencyProbe = (state, payload, receivedAtMs) => {
85
+ const clientSentAtMs = Number(payload?.client_sent_at_ms);
86
+ const serverReceivedAtMs = Number(payload?.server_received_at_ms);
87
+ const serverSentAtMs = Number(payload?.server_sent_at_ms);
88
+ const browserReceivedAtMs = Number(receivedAtMs);
89
+
90
+ if (
91
+ !Number.isFinite(clientSentAtMs) ||
92
+ !Number.isFinite(serverReceivedAtMs) ||
93
+ !Number.isFinite(serverSentAtMs) ||
94
+ !Number.isFinite(browserReceivedAtMs) ||
95
+ browserReceivedAtMs < clientSentAtMs ||
96
+ serverSentAtMs < serverReceivedAtMs
97
+ ) {
98
+ return { ...state };
99
+ }
100
+
101
+ const serverProcessingMs = serverSentAtMs - serverReceivedAtMs;
102
+ const rttMs = Math.max(0, browserReceivedAtMs - clientSentAtMs - serverProcessingMs);
103
+ const clockOffsetMs = ((serverReceivedAtMs - clientSentAtMs) + (serverSentAtMs - browserReceivedAtMs)) / 2;
104
+
105
+ return {
106
+ ...state,
107
+ clockOffsetMs: Math.round(clockOffsetMs),
108
+ rttMs: Math.round(rttMs),
109
+ };
110
+ };
111
+
112
+ export const recordConnectionStatus = (state, status) => {
113
+ if (status !== "reconnecting") {
114
+ return { ...state };
115
+ }
116
+
117
+ return {
118
+ ...state,
119
+ reconnects: Number(state?.reconnects || 0) + 1,
120
+ };
121
+ };
122
+
123
+ export const recordShaderCompile = (state, detail) => {
124
+ const compileMs = coerceMetric(detail?.compileMs);
125
+ if (compileMs === null) {
126
+ return { ...state };
127
+ }
128
+
129
+ return {
130
+ ...state,
131
+ shaderCompileMs: compileMs,
132
+ };
133
+ };
134
+
135
+ export const formatPerformanceMonitorText = (state) => {
136
+ const fps = Number(state?.fps || 0) > 0 ? Number(state.fps).toFixed(1) : "--";
137
+ const frameMs = Number(state?.frameMs || 0) > 0 ? `${Number(state.frameMs).toFixed(1)}ms` : "--";
138
+ const wsLatency = Number.isFinite(state?.wsLatencyMs) ? `${Math.round(state.wsLatencyMs)}ms` : "--";
139
+ const rtt = Number.isFinite(state?.rttMs) ? `${Math.round(state.rttMs)}ms` : "--";
140
+ const clockOffset = Number.isFinite(state?.clockOffsetMs) ? `${formatSignedInteger(state.clockOffsetMs)}ms` : "--";
141
+ const audioLatency = Number.isFinite(state?.audioLatencyMs) ? `${Number(state.audioLatencyMs).toFixed(1)}ms` : "--";
142
+ const shaderCompile = Number.isFinite(state?.shaderCompileMs) ? `${Number(state.shaderCompileMs).toFixed(1)}ms` : "--";
143
+ const droppedFrames = Math.max(0, Number(state?.droppedFrames || 0));
144
+ const reconnects = Math.max(0, Number(state?.reconnects || 0));
145
+
146
+ return `Perf: ${fps} FPS | Frame ${frameMs} | WS ${wsLatency} | RTT ${rtt} | Clock ${clockOffset} | Drop ${droppedFrames} | Audio ${audioLatency} | Shader ${shaderCompile} | Reconnect ${reconnects}`;
147
+ };
148
+
149
+ export const estimateDroppedFrames = (frameGapMs, expectedFrameMs = DEFAULT_EXPECTED_FRAME_MS) => {
150
+ const gap = Number(frameGapMs);
151
+ const expected = Number(expectedFrameMs);
152
+ if (!Number.isFinite(gap) || !Number.isFinite(expected) || gap <= expected * 1.75) {
153
+ return 0;
154
+ }
155
+
156
+ return Math.max(0, Math.round(gap / expected) - 1);
157
+ };
158
+
159
+ const audioLatencyFromMetrics = (metrics, fallback) => {
160
+ const captureMs = coerceMetric(metrics?.audio_capture_ms);
161
+ const analysisMs = coerceMetric(metrics?.audio_analysis_ms);
162
+ if (captureMs === null && analysisMs === null) {
163
+ return fallback ?? null;
164
+ }
165
+
166
+ return (captureMs || 0) + (analysisMs || 0);
167
+ };
168
+
169
+ const coerceMetric = (value) => {
170
+ const numeric = Number(value);
171
+ if (!Number.isFinite(numeric)) {
172
+ return null;
173
+ }
174
+
175
+ return Math.max(0, numeric);
176
+ };
177
+
178
+ const roundOneDecimal = (value) => Math.round(value * 10) / 10;
179
+
180
+ const formatSignedInteger = (value) => {
181
+ const rounded = Math.round(Number(value) || 0);
182
+ return rounded > 0 ? `+${rounded}` : `${rounded}`;
183
+ };
@@ -0,0 +1,130 @@
1
+ export const VIZCORE_PLUGIN_API_VERSION = 1;
2
+
3
+ const layerRenderers = new Map();
4
+ const shaderRenderers = new Map();
5
+
6
+ export const registerLayerRenderer = (type, renderer) => {
7
+ const key = normalizeLayerType(type);
8
+ if (!key || typeof renderer !== "function") {
9
+ return false;
10
+ }
11
+
12
+ layerRenderers.set(key, renderer);
13
+ return true;
14
+ };
15
+
16
+ export const unregisterLayerRenderer = (type) => {
17
+ const key = normalizeLayerType(type);
18
+ return key ? layerRenderers.delete(key) : false;
19
+ };
20
+
21
+ export const resolveLayerRenderer = (type) => {
22
+ return layerRenderers.get(normalizeLayerType(type)) || null;
23
+ };
24
+
25
+ export const registeredLayerRendererTypes = () => Array.from(layerRenderers.keys()).sort();
26
+
27
+ export const registerShaderRenderer = (type, renderer) => {
28
+ const key = normalizeLayerType(type);
29
+ if (!key || typeof renderer !== "function") {
30
+ return false;
31
+ }
32
+
33
+ shaderRenderers.set(key, renderer);
34
+ return true;
35
+ };
36
+
37
+ export const unregisterShaderRenderer = (type) => {
38
+ const key = normalizeLayerType(type);
39
+ return key ? shaderRenderers.delete(key) : false;
40
+ };
41
+
42
+ export const resolveShaderRenderer = (type) => {
43
+ return shaderRenderers.get(normalizeLayerType(type)) || null;
44
+ };
45
+
46
+ export const registeredShaderRendererTypes = () => Array.from(shaderRenderers.keys()).sort();
47
+
48
+ export const normalizePluginLineOutput = (output) => {
49
+ const input = output && typeof output === "object" ? output : {};
50
+ const kind = String(input.kind || "lines").toLowerCase();
51
+ if (kind !== "lines") {
52
+ return null;
53
+ }
54
+
55
+ const points = Array.isArray(input.points) || ArrayBuffer.isView(input.points)
56
+ ? Array.from(input.points).map((value) => Number(value)).filter(Number.isFinite)
57
+ : [];
58
+ if (points.length < 4) {
59
+ return null;
60
+ }
61
+
62
+ return {
63
+ kind: "lines",
64
+ points: points.length % 2 === 0 ? points : points.slice(0, -1),
65
+ color: normalizeRgb(input.color),
66
+ };
67
+ };
68
+
69
+ export const normalizePluginShaderOutput = (output) => {
70
+ const input = typeof output === "string"
71
+ ? { kind: "shader", fragmentShader: output }
72
+ : output && typeof output === "object" ? output : {};
73
+ const kind = String(input.kind || "shader").toLowerCase();
74
+ if (kind !== "shader") {
75
+ return null;
76
+ }
77
+
78
+ const fragmentShader = String(input.fragmentShader || input.source || input.glsl || "").trim();
79
+ if (!fragmentShader) {
80
+ return null;
81
+ }
82
+
83
+ return {
84
+ kind: "shader",
85
+ fragmentShader,
86
+ cacheKey: String(input.cacheKey || input.name || "plugin-shader"),
87
+ };
88
+ };
89
+
90
+ export const installGlobalPluginRuntime = (target = globalThis) => {
91
+ if (!target || typeof target !== "object") {
92
+ return null;
93
+ }
94
+
95
+ const runtime = {
96
+ apiVersion: VIZCORE_PLUGIN_API_VERSION,
97
+ registerLayerRenderer,
98
+ unregisterLayerRenderer,
99
+ resolveLayerRenderer,
100
+ registeredLayerRendererTypes,
101
+ registerShaderRenderer,
102
+ unregisterShaderRenderer,
103
+ resolveShaderRenderer,
104
+ registeredShaderRendererTypes,
105
+ };
106
+ target.VizcorePlugins = {
107
+ ...(target.VizcorePlugins || {}),
108
+ ...runtime,
109
+ };
110
+ if (typeof target.dispatchEvent === "function" && typeof target.Event === "function") {
111
+ target.dispatchEvent(new target.Event("vizcore:plugins-ready"));
112
+ }
113
+ return target.VizcorePlugins;
114
+ };
115
+
116
+ const normalizeLayerType = (type) => String(type || "").trim().toLowerCase();
117
+
118
+ const normalizeRgb = (value) => {
119
+ const values = Array.isArray(value) ? value : [];
120
+ if (values.length < 3) {
121
+ return null;
122
+ }
123
+
124
+ const rgb = values.slice(0, 3).map((entry) => Number(entry));
125
+ return rgb.every(Number.isFinite)
126
+ ? rgb.map((entry) => Math.min(Math.max(entry, 0), 1))
127
+ : null;
128
+ };
129
+
130
+ installGlobalPluginRuntime();
@@ -0,0 +1,56 @@
1
+ export const resolveProjectorMode = ({ body, current = false, location, runtime } = {}) => {
2
+ if (controlModeFromBody(body) || controlModeFromLocation(location)) {
3
+ return false;
4
+ }
5
+
6
+ return current
7
+ || projectorModeFromBody(body)
8
+ || projectorModeFromLocation(location)
9
+ || projectorModeFromRuntime(runtime);
10
+ };
11
+
12
+ export const applyProjectorMode = (body, enabled) => {
13
+ if (!body) {
14
+ return;
15
+ }
16
+
17
+ const active = !!enabled;
18
+ if (body.classList && typeof body.classList.toggle === "function") {
19
+ body.classList.toggle("is-projector", active);
20
+ }
21
+
22
+ if (body.dataset) {
23
+ body.dataset.projectorMode = active ? "true" : "false";
24
+ }
25
+ };
26
+
27
+ export const projectorModeFromRuntime = (runtime) => truthyValue(runtime?.projector_mode);
28
+
29
+ export const projectorModeFromBody = (body) => truthyValue(body?.dataset?.projectorMode);
30
+
31
+ export const controlModeFromBody = (body) => body?.dataset?.displayMode === "control";
32
+
33
+ export const projectorModeFromLocation = (location) => {
34
+ const search = String(location?.search || "");
35
+ if (!search) {
36
+ return false;
37
+ }
38
+
39
+ const params = new URLSearchParams(search);
40
+ return truthyValue(params.get("projector")) || params.get("mode") === "projector";
41
+ };
42
+
43
+ export const controlModeFromLocation = (location) => {
44
+ const search = String(location?.search || "");
45
+ if (!search) {
46
+ return false;
47
+ }
48
+
49
+ const params = new URLSearchParams(search);
50
+ return truthyValue(params.get("control")) || params.get("mode") === "control";
51
+ };
52
+
53
+ const truthyValue = (value) => {
54
+ const normalized = String(value || "").trim().toLowerCase();
55
+ return normalized === "1" || normalized === "true" || normalized === "yes";
56
+ };