vizcore 1.0.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.
- checksums.yaml +4 -4
- data/README.md +66 -648
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/index.html +2 -1
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/frontend/index.html +26 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/main.js +268 -0
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/renderer/engine.js +10 -1
- data/frontend/src/renderer/layer-manager.js +18 -4
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visuals/geometry.js +425 -27
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/lib/vizcore/cli/dsl_reference.rb +1 -1
- data/lib/vizcore/cli/scene_validator.rb +92 -0
- data/lib/vizcore/dsl/layer_builder.rb +795 -7
- data/lib/vizcore/dsl/mapping_resolver.rb +158 -4
- data/lib/vizcore/layer_catalog.rb +4 -2
- data/lib/vizcore/renderer/scene_frame_source.rb +14 -1
- data/lib/vizcore/renderer/snapshot_renderer.rb +507 -15
- data/lib/vizcore/server/frame_broadcaster.rb +53 -4
- data/lib/vizcore/server/runner.rb +21 -0
- data/lib/vizcore/shape.rb +719 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +1 -0
- data/sig/vizcore.rbs +100 -1
- metadata +12 -1
data/frontend/src/main.js
CHANGED
|
@@ -31,10 +31,24 @@ import {
|
|
|
31
31
|
import { applyProjectorMode, resolveProjectorMode } from "./projector-mode.js";
|
|
32
32
|
import { Engine } from "./renderer/engine.js";
|
|
33
33
|
import { SHADER_COMPILE_EVENT } from "./renderer/shader-manager.js";
|
|
34
|
+
import {
|
|
35
|
+
customShapeParamControlEntries,
|
|
36
|
+
customShapeParamMessage,
|
|
37
|
+
pruneCustomShapeParamOverrides
|
|
38
|
+
} from "./custom-shape-param-controls.js";
|
|
39
|
+
import {
|
|
40
|
+
mappingTargetOptions,
|
|
41
|
+
mappingTargetSignature
|
|
42
|
+
} from "./mapping-target-selector.js";
|
|
34
43
|
import {
|
|
35
44
|
pruneShaderParamOverrides,
|
|
36
45
|
shaderParamControlEntries
|
|
37
46
|
} from "./shader-param-controls.js";
|
|
47
|
+
import {
|
|
48
|
+
normalizeShapeEditorPatch,
|
|
49
|
+
pruneShapeEditorOverrides,
|
|
50
|
+
shapeEditorEntries
|
|
51
|
+
} from "./shape-editor-controls.js";
|
|
38
52
|
import { normalizeRuntimeControlPreset } from "./runtime-control-preset.js";
|
|
39
53
|
import { SHADER_ERROR_EVENT, formatShaderErrorMessage, formatShaderErrorTitle } from "./shader-error-overlay.js";
|
|
40
54
|
import {
|
|
@@ -91,6 +105,9 @@ const reactivityStatusElement = document.querySelector("#reactivity-status");
|
|
|
91
105
|
const midiLearnStatusElement = document.querySelector("#midi-learn-status");
|
|
92
106
|
const midiLearnButtons = Array.from(document.querySelectorAll("[data-midi-learn-action]"));
|
|
93
107
|
const shaderParamControlsElement = document.querySelector("#shader-param-controls");
|
|
108
|
+
const shapeEditorControlsElement = document.querySelector("#shape-editor-controls");
|
|
109
|
+
const customShapeParamControlsElement = document.querySelector("#custom-shape-param-controls");
|
|
110
|
+
const mappingTargetSelectorElement = document.querySelector("#mapping-target-selector");
|
|
94
111
|
const shaderErrorOverlay = document.querySelector("#shader-error-overlay");
|
|
95
112
|
const shaderErrorTitleElement = document.querySelector("#shader-error-title");
|
|
96
113
|
const shaderErrorMessageElement = document.querySelector("#shader-error-message");
|
|
@@ -119,6 +136,12 @@ let runtimeControlPresetApplied = false;
|
|
|
119
136
|
let controlPresetSaveUrl = null;
|
|
120
137
|
let shaderParamOverrides = {};
|
|
121
138
|
let shaderParamControlsSignature = "";
|
|
139
|
+
let shapeEditorOverrides = {};
|
|
140
|
+
let shapeEditorControlsSignature = "";
|
|
141
|
+
let customShapeParamOverrides = {};
|
|
142
|
+
let customShapeParamControlsSignature = "";
|
|
143
|
+
let mappingTargetSelectorSignature = "";
|
|
144
|
+
let selectedMappingTarget = "";
|
|
122
145
|
let midiAccess = null;
|
|
123
146
|
let pendingMidiLearnAction = null;
|
|
124
147
|
applyProjectorMode(document.body, projectorMode);
|
|
@@ -180,8 +203,14 @@ const client = new WebSocketClient(websocketUrl, {
|
|
|
180
203
|
currentSceneName = sceneName;
|
|
181
204
|
if (sceneChanged) {
|
|
182
205
|
shaderParamControlsSignature = "";
|
|
206
|
+
shapeEditorControlsSignature = "";
|
|
207
|
+
customShapeParamControlsSignature = "";
|
|
208
|
+
mappingTargetSelectorSignature = "";
|
|
183
209
|
}
|
|
184
210
|
updateShaderParamControls(frame?.scene?.layers);
|
|
211
|
+
updateShapeEditorControls(frame?.scene?.layers);
|
|
212
|
+
updateCustomShapeParamControls(frame?.scene?.layers);
|
|
213
|
+
updateMappingTargetSelector(frame?.scene?.layers);
|
|
185
214
|
const amplitude = Number(frame?.audio?.amplitude || 0).toFixed(4);
|
|
186
215
|
const bpm = Number(frame?.audio?.bpm || 0);
|
|
187
216
|
const beat = !!frame?.audio?.beat;
|
|
@@ -218,7 +247,13 @@ const client = new WebSocketClient(websocketUrl, {
|
|
|
218
247
|
sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
|
|
219
248
|
renderSceneButtons();
|
|
220
249
|
shaderParamControlsSignature = "";
|
|
250
|
+
shapeEditorControlsSignature = "";
|
|
251
|
+
customShapeParamControlsSignature = "";
|
|
252
|
+
mappingTargetSelectorSignature = "";
|
|
221
253
|
updateShaderParamControls(payload?.scene?.layers);
|
|
254
|
+
updateShapeEditorControls(payload?.scene?.layers);
|
|
255
|
+
updateCustomShapeParamControls(payload?.scene?.layers);
|
|
256
|
+
updateMappingTargetSelector(payload?.scene?.layers);
|
|
222
257
|
}
|
|
223
258
|
if (Object.prototype.hasOwnProperty.call(payload || {}, "tap_tempo_key")) {
|
|
224
259
|
updateTapTempoKey(payload?.tap_tempo_key);
|
|
@@ -494,6 +529,239 @@ function formatShaderParamValue(value) {
|
|
|
494
529
|
return Math.abs(numeric) >= 10 ? numeric.toFixed(1) : numeric.toFixed(2);
|
|
495
530
|
}
|
|
496
531
|
|
|
532
|
+
function updateShapeEditorControls(layers) {
|
|
533
|
+
const entries = shapeEditorEntries(layers, shapeEditorOverrides);
|
|
534
|
+
const signature = shapeEditorControlsSignatureFor(entries);
|
|
535
|
+
if (signature === shapeEditorControlsSignature) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
shapeEditorControlsSignature = signature;
|
|
540
|
+
shapeEditorOverrides = pruneShapeEditorOverrides(shapeEditorOverrides, entries);
|
|
541
|
+
engine.setShapeEditorOverrides(shapeEditorOverrides);
|
|
542
|
+
renderShapeEditorControls(entries);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function shapeEditorControlsSignatureFor(entries) {
|
|
546
|
+
return entries.map((entry) => (
|
|
547
|
+
`${entry.key}:${entry.kind}:${JSON.stringify(entry.values)}`
|
|
548
|
+
)).join("|");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderShapeEditorControls(entries) {
|
|
552
|
+
if (!shapeEditorControlsElement) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!entries.length) {
|
|
557
|
+
shapeEditorControlsElement.hidden = true;
|
|
558
|
+
shapeEditorControlsElement.replaceChildren();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const title = document.createElement("p");
|
|
563
|
+
title.className = "shader-param-controls__title";
|
|
564
|
+
title.textContent = "Shape Editor";
|
|
565
|
+
const controls = entries.map((entry) => createShapeEditorControl(entry));
|
|
566
|
+
shapeEditorControlsElement.replaceChildren(title, ...controls);
|
|
567
|
+
shapeEditorControlsElement.hidden = false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function createShapeEditorControl(entry) {
|
|
571
|
+
const section = document.createElement("details");
|
|
572
|
+
const summary = document.createElement("summary");
|
|
573
|
+
summary.textContent = entry.label;
|
|
574
|
+
section.append(summary);
|
|
575
|
+
section.append(
|
|
576
|
+
createShapeKindControl(entry),
|
|
577
|
+
createShapeNumberControl(entry, "translateX", "Move X", -640, 640, 1),
|
|
578
|
+
createShapeNumberControl(entry, "translateY", "Move Y", -360, 360, 1),
|
|
579
|
+
createShapeNumberControl(entry, "rotate", "Rotate", -180, 180, 1),
|
|
580
|
+
createShapeNumberControl(entry, "scaleX", "Scale X", -4, 4, 0.05),
|
|
581
|
+
createShapeNumberControl(entry, "scaleY", "Scale Y", -4, 4, 0.05),
|
|
582
|
+
createShapeNumberControl(entry, "opacity", "Opacity", 0, 1, 0.05),
|
|
583
|
+
createShapeColorControl(entry, "fill", "Fill"),
|
|
584
|
+
createShapeColorControl(entry, "strokeColor", "Stroke"),
|
|
585
|
+
createShapeNumberControl(entry, "strokeWidth", "Stroke W", 0, 24, 0.5)
|
|
586
|
+
);
|
|
587
|
+
return section;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function createShapeKindControl(entry) {
|
|
591
|
+
const label = document.createElement("label");
|
|
592
|
+
const name = document.createElement("span");
|
|
593
|
+
const select = document.createElement("select");
|
|
594
|
+
name.textContent = "Kind";
|
|
595
|
+
["circle", "line", "rect", "polygon", "polyline", "path", "star"].forEach((kind) => {
|
|
596
|
+
const option = document.createElement("option");
|
|
597
|
+
option.value = kind;
|
|
598
|
+
option.textContent = kind;
|
|
599
|
+
select.append(option);
|
|
600
|
+
});
|
|
601
|
+
select.value = entry.kind;
|
|
602
|
+
select.addEventListener("change", () => {
|
|
603
|
+
writeShapeEditorOverride(entry, { ...entry.values, kind: select.value });
|
|
604
|
+
});
|
|
605
|
+
label.append(name, select);
|
|
606
|
+
return label;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function createShapeNumberControl(entry, key, labelText, min, max, step) {
|
|
610
|
+
const label = document.createElement("label");
|
|
611
|
+
const name = document.createElement("span");
|
|
612
|
+
const input = document.createElement("input");
|
|
613
|
+
const value = document.createElement("output");
|
|
614
|
+
name.textContent = labelText;
|
|
615
|
+
input.type = "range";
|
|
616
|
+
input.min = String(min);
|
|
617
|
+
input.max = String(max);
|
|
618
|
+
input.step = String(step);
|
|
619
|
+
input.value = String(entry.values[key]);
|
|
620
|
+
value.value = formatShaderParamValue(entry.values[key]);
|
|
621
|
+
input.addEventListener("input", () => {
|
|
622
|
+
const numeric = Number(input.value);
|
|
623
|
+
writeShapeEditorOverride(entry, { ...entry.values, [key]: numeric });
|
|
624
|
+
value.value = formatShaderParamValue(numeric);
|
|
625
|
+
});
|
|
626
|
+
label.append(name, input, value);
|
|
627
|
+
return label;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function createShapeColorControl(entry, key, labelText) {
|
|
631
|
+
const label = document.createElement("label");
|
|
632
|
+
const name = document.createElement("span");
|
|
633
|
+
const input = document.createElement("input");
|
|
634
|
+
const value = document.createElement("output");
|
|
635
|
+
name.textContent = labelText;
|
|
636
|
+
input.type = "color";
|
|
637
|
+
input.value = entry.values[key];
|
|
638
|
+
value.value = entry.values[key];
|
|
639
|
+
input.addEventListener("input", () => {
|
|
640
|
+
writeShapeEditorOverride(entry, { ...entry.values, [key]: input.value, [`${key}Enabled`]: true });
|
|
641
|
+
value.value = input.value;
|
|
642
|
+
});
|
|
643
|
+
label.append(name, input, value);
|
|
644
|
+
return label;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function writeShapeEditorOverride(entry, values) {
|
|
648
|
+
shapeEditorOverrides[entry.layerKey] ||= {};
|
|
649
|
+
shapeEditorOverrides[entry.layerKey][entry.shapeIndex] = normalizeShapeEditorPatch(values);
|
|
650
|
+
engine.setShapeEditorOverrides(shapeEditorOverrides);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function updateCustomShapeParamControls(layers) {
|
|
654
|
+
const entries = customShapeParamControlEntries(layers, customShapeParamOverrides);
|
|
655
|
+
const signature = customShapeParamControlsSignatureFor(entries);
|
|
656
|
+
if (signature === customShapeParamControlsSignature) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
customShapeParamControlsSignature = signature;
|
|
661
|
+
customShapeParamOverrides = pruneCustomShapeParamOverrides(customShapeParamOverrides, entries);
|
|
662
|
+
renderCustomShapeParamControls(entries);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function customShapeParamControlsSignatureFor(entries) {
|
|
666
|
+
return entries.map((entry) => (
|
|
667
|
+
`${entry.key}:${entry.min}:${entry.max}:${entry.step}:${entry.value}`
|
|
668
|
+
)).join("|");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function renderCustomShapeParamControls(entries) {
|
|
672
|
+
if (!customShapeParamControlsElement) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (!entries.length) {
|
|
677
|
+
customShapeParamControlsElement.hidden = true;
|
|
678
|
+
customShapeParamControlsElement.replaceChildren();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const title = document.createElement("p");
|
|
683
|
+
title.className = "shader-param-controls__title";
|
|
684
|
+
title.textContent = "Custom Shape Params";
|
|
685
|
+
const controls = entries.map((entry) => createCustomShapeParamControl(entry));
|
|
686
|
+
customShapeParamControlsElement.replaceChildren(title, ...controls);
|
|
687
|
+
customShapeParamControlsElement.hidden = false;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function createCustomShapeParamControl(entry) {
|
|
691
|
+
const label = document.createElement("label");
|
|
692
|
+
const name = document.createElement("span");
|
|
693
|
+
const input = document.createElement("input");
|
|
694
|
+
const value = document.createElement("output");
|
|
695
|
+
name.textContent = entry.label;
|
|
696
|
+
input.type = "range";
|
|
697
|
+
input.min = String(entry.min);
|
|
698
|
+
input.max = String(entry.max);
|
|
699
|
+
input.step = String(entry.step);
|
|
700
|
+
input.value = String(entry.value);
|
|
701
|
+
value.value = formatShaderParamValue(entry.value);
|
|
702
|
+
input.addEventListener("input", () => {
|
|
703
|
+
const numeric = Number(input.value);
|
|
704
|
+
customShapeParamOverrides[entry.layerKey] ||= {};
|
|
705
|
+
customShapeParamOverrides[entry.layerKey][entry.customShapeIndex] ||= {};
|
|
706
|
+
customShapeParamOverrides[entry.layerKey][entry.customShapeIndex][entry.paramName] = numeric;
|
|
707
|
+
client.send("custom_shape_param", customShapeParamMessage(entry, numeric));
|
|
708
|
+
value.value = formatShaderParamValue(numeric);
|
|
709
|
+
});
|
|
710
|
+
label.append(name, input, value);
|
|
711
|
+
return label;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function updateMappingTargetSelector(layers) {
|
|
715
|
+
const options = mappingTargetOptions(layers);
|
|
716
|
+
const signature = mappingTargetSignature(options);
|
|
717
|
+
if (signature === mappingTargetSelectorSignature) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
mappingTargetSelectorSignature = signature;
|
|
722
|
+
renderMappingTargetSelector(options);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function renderMappingTargetSelector(options) {
|
|
726
|
+
if (!mappingTargetSelectorElement) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!options.length) {
|
|
731
|
+
selectedMappingTarget = "";
|
|
732
|
+
mappingTargetSelectorElement.hidden = true;
|
|
733
|
+
mappingTargetSelectorElement.replaceChildren();
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const title = document.createElement("p");
|
|
738
|
+
title.className = "shader-param-controls__title";
|
|
739
|
+
title.textContent = "Mapping Targets";
|
|
740
|
+
const label = document.createElement("label");
|
|
741
|
+
const name = document.createElement("span");
|
|
742
|
+
const select = document.createElement("select");
|
|
743
|
+
const output = document.createElement("output");
|
|
744
|
+
name.textContent = "Target";
|
|
745
|
+
options.forEach((option) => {
|
|
746
|
+
const item = document.createElement("option");
|
|
747
|
+
item.value = option.target;
|
|
748
|
+
item.textContent = option.label;
|
|
749
|
+
select.append(item);
|
|
750
|
+
});
|
|
751
|
+
if (!options.some((option) => option.target === selectedMappingTarget)) {
|
|
752
|
+
selectedMappingTarget = options[0].target;
|
|
753
|
+
}
|
|
754
|
+
select.value = selectedMappingTarget;
|
|
755
|
+
output.value = selectedMappingTarget;
|
|
756
|
+
select.addEventListener("change", () => {
|
|
757
|
+
selectedMappingTarget = select.value;
|
|
758
|
+
output.value = select.value;
|
|
759
|
+
});
|
|
760
|
+
label.append(name, select, output);
|
|
761
|
+
mappingTargetSelectorElement.replaceChildren(title, label);
|
|
762
|
+
mappingTargetSelectorElement.hidden = false;
|
|
763
|
+
}
|
|
764
|
+
|
|
497
765
|
function requestSceneSwitch(sceneName) {
|
|
498
766
|
if (!sceneName || sceneName === currentSceneName) {
|
|
499
767
|
return;
|
|
@@ -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
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LayerManager } from "./layer-manager.js";
|
|
2
2
|
import { ShaderManager } from "./shader-manager.js";
|
|
3
3
|
import { applyShaderParamOverrides } from "../shader-param-controls.js";
|
|
4
|
+
import { applyShapeEditorOverrides } from "../shape-editor-controls.js";
|
|
4
5
|
|
|
5
6
|
export class Engine {
|
|
6
7
|
constructor(canvas) {
|
|
@@ -27,6 +28,7 @@ export class Engine {
|
|
|
27
28
|
};
|
|
28
29
|
this.runtimeGlobals = {};
|
|
29
30
|
this.shaderParamOverrides = {};
|
|
31
|
+
this.shapeEditorOverrides = {};
|
|
30
32
|
this.beatHoldUntil = 0;
|
|
31
33
|
this.frame = {
|
|
32
34
|
audio: {
|
|
@@ -98,6 +100,10 @@ export class Engine {
|
|
|
98
100
|
this.shaderParamOverrides = overrides && typeof overrides === "object" ? overrides : {};
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
setShapeEditorOverrides(overrides = {}) {
|
|
104
|
+
this.shapeEditorOverrides = overrides && typeof overrides === "object" ? overrides : {};
|
|
105
|
+
}
|
|
106
|
+
|
|
101
107
|
start() {
|
|
102
108
|
this.lastTime = performance.now();
|
|
103
109
|
requestAnimationFrame((time) => this.render(time));
|
|
@@ -156,7 +162,10 @@ export class Engine {
|
|
|
156
162
|
});
|
|
157
163
|
this.visualAudioState = audio;
|
|
158
164
|
const rawLayers = Array.isArray(this.frame?.scene?.layers) ? this.frame.scene.layers : [];
|
|
159
|
-
const layers =
|
|
165
|
+
const layers = applyShapeEditorOverrides(
|
|
166
|
+
applyShaderParamOverrides(rawLayers, this.shaderParamOverrides),
|
|
167
|
+
this.shapeEditorOverrides
|
|
168
|
+
);
|
|
160
169
|
const amplitude = clamp(Number(audio.amplitude || 0), 0, 1);
|
|
161
170
|
const rotationSpeed = resolveRotationSpeed(layers, amplitude);
|
|
162
171
|
this.currentRotationSpeed += (rotationSpeed - this.currentRotationSpeed) * 0.1;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveShaderRenderer
|
|
19
19
|
} from "../plugin-runtime.js";
|
|
20
20
|
import { SpectrogramRenderer } from "../visuals/spectrogram-renderer.js";
|
|
21
|
+
import { ShapeRenderer } from "../visuals/shape-renderer.js";
|
|
21
22
|
import { TextRenderer } from "../visuals/text-renderer.js";
|
|
22
23
|
import { getVJEffectShader } from "../visuals/vj-effects.js";
|
|
23
24
|
import { FULLSCREEN_VERTEX_SHADER } from "./shader-manager.js";
|
|
@@ -205,6 +206,7 @@ export class LayerManager {
|
|
|
205
206
|
this.particleSystem = new ParticleSystem(this.gl, this.shaderManager);
|
|
206
207
|
this.textRenderer = new TextRenderer(this.gl, this.shaderManager);
|
|
207
208
|
this.imageRenderer = new ImageRenderer(this.gl, this.shaderManager);
|
|
209
|
+
this.shapeRenderer = new ShapeRenderer(this.gl, this.shaderManager);
|
|
208
210
|
this.spectrogramRenderer = new SpectrogramRenderer(this.gl, this.shaderManager);
|
|
209
211
|
|
|
210
212
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.fullscreenBuffer);
|
|
@@ -274,7 +276,7 @@ export class LayerManager {
|
|
|
274
276
|
return;
|
|
275
277
|
}
|
|
276
278
|
if (isShapeLayer(layer)) {
|
|
277
|
-
this.renderShapeLayer(layer, audio, paletteIndex);
|
|
279
|
+
this.renderShapeLayer(layer, audio, time, resolution, paletteIndex);
|
|
278
280
|
return;
|
|
279
281
|
}
|
|
280
282
|
if (isShaderLayer(layer)) {
|
|
@@ -556,16 +558,28 @@ export class LayerManager {
|
|
|
556
558
|
});
|
|
557
559
|
}
|
|
558
560
|
|
|
559
|
-
renderShapeLayer(layer, audio, paletteIndex = 0) {
|
|
561
|
+
renderShapeLayer(layer, audio, time, resolution, paletteIndex = 0) {
|
|
560
562
|
const params = layer?.params || {};
|
|
563
|
+
const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
|
|
564
|
+
const fallbackColor = [0.85, 0.50 + amplitude * 0.24, 0.95];
|
|
565
|
+
const cssColor = resolveLayerCssColor(params, "#d98cff", paletteIndex);
|
|
566
|
+
const rendered = this.shapeRenderer.render({
|
|
567
|
+
params,
|
|
568
|
+
color: cssColor,
|
|
569
|
+
resolution,
|
|
570
|
+
audio,
|
|
571
|
+
time
|
|
572
|
+
});
|
|
573
|
+
if (rendered) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
561
577
|
const points = buildShapeLines({ params });
|
|
562
578
|
|
|
563
579
|
if (points.length === 0) {
|
|
564
580
|
return;
|
|
565
581
|
}
|
|
566
582
|
|
|
567
|
-
const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
|
|
568
|
-
const fallbackColor = [0.85, 0.50 + amplitude * 0.24, 0.95];
|
|
569
583
|
const color = resolveLayerRgbColor(params, fallbackColor, paletteIndex);
|
|
570
584
|
this.renderLinePoints(points, color);
|
|
571
585
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const SHAPE_KINDS = ["circle", "line", "rect", "polygon", "polyline", "path", "star"];
|
|
2
|
+
|
|
3
|
+
export const shapeEditorLayerKey = (layer, index) => `${index}:${String(layer?.name || "layer")}`;
|
|
4
|
+
|
|
5
|
+
export const shapeEditorEntries = (layers, overrides = {}) => {
|
|
6
|
+
const entries = [];
|
|
7
|
+
const layerList = Array.isArray(layers) ? layers : [];
|
|
8
|
+
|
|
9
|
+
layerList.forEach((layer, layerIndex) => {
|
|
10
|
+
const shapes = Array.isArray(layer?.params?.shapes) ? layer.params.shapes : [];
|
|
11
|
+
if (!shapes.length) return;
|
|
12
|
+
|
|
13
|
+
const layerKey = shapeEditorLayerKey(layer, layerIndex);
|
|
14
|
+
const layerName = String(layer?.name || `layer_${layerIndex + 1}`);
|
|
15
|
+
shapes.forEach((shape, shapeIndex) => {
|
|
16
|
+
const edited = applyShapePatch(shape, overrides?.[layerKey]?.[shapeIndex]);
|
|
17
|
+
const id = String(edited?.id || `shape_${shapeIndex + 1}`);
|
|
18
|
+
const kind = normalizeKind(edited?.kind || edited?.type);
|
|
19
|
+
entries.push({
|
|
20
|
+
key: `${layerKey}:shapes.${shapeIndex}`,
|
|
21
|
+
layerKey,
|
|
22
|
+
layerName,
|
|
23
|
+
shapeIndex,
|
|
24
|
+
shapeId: id,
|
|
25
|
+
label: `${layerName}.${id}`,
|
|
26
|
+
kind,
|
|
27
|
+
values: shapeEditorValues(edited, kind),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return entries;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const applyShapeEditorOverrides = (layers, overrides = {}) => {
|
|
36
|
+
const layerList = Array.isArray(layers) ? layers : [];
|
|
37
|
+
|
|
38
|
+
return layerList.map((layer, layerIndex) => {
|
|
39
|
+
const shapes = Array.isArray(layer?.params?.shapes) ? layer.params.shapes : null;
|
|
40
|
+
if (!shapes) return layer;
|
|
41
|
+
|
|
42
|
+
const layerKey = shapeEditorLayerKey(layer, layerIndex);
|
|
43
|
+
const layerOverrides = overrides?.[layerKey];
|
|
44
|
+
if (!layerOverrides || typeof layerOverrides !== "object") return layer;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
...layer,
|
|
48
|
+
params: {
|
|
49
|
+
...(layer?.params || {}),
|
|
50
|
+
shapes: shapes.map((shape, shapeIndex) => applyShapePatch(shape, layerOverrides[shapeIndex])),
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const pruneShapeEditorOverrides = (overrides = {}, entries = []) => {
|
|
57
|
+
const validKeys = new Set(entries.map((entry) => `${entry.layerKey}:${entry.shapeIndex}`));
|
|
58
|
+
const next = {};
|
|
59
|
+
|
|
60
|
+
Object.entries(overrides || {}).forEach(([layerKey, layerOverrides]) => {
|
|
61
|
+
if (!layerOverrides || typeof layerOverrides !== "object") return;
|
|
62
|
+
|
|
63
|
+
Object.entries(layerOverrides).forEach(([shapeIndex, patch]) => {
|
|
64
|
+
if (!validKeys.has(`${layerKey}:${shapeIndex}`) || !patch || typeof patch !== "object") return;
|
|
65
|
+
|
|
66
|
+
next[layerKey] ||= {};
|
|
67
|
+
next[layerKey][shapeIndex] = patch;
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return next;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const normalizeShapeEditorPatch = (values = {}) => {
|
|
75
|
+
const kind = normalizeKind(values.kind);
|
|
76
|
+
const output = {
|
|
77
|
+
kind,
|
|
78
|
+
opacity: clamp(finiteNumber(values.opacity, 1), 0, 1),
|
|
79
|
+
stroke_width: Math.max(0, finiteNumber(values.strokeWidth ?? values.stroke_width, 1)),
|
|
80
|
+
transform: {
|
|
81
|
+
translate: {
|
|
82
|
+
x: finiteNumber(values.translateX, 0),
|
|
83
|
+
y: finiteNumber(values.translateY, 0),
|
|
84
|
+
},
|
|
85
|
+
rotate: finiteNumber(values.rotate, 0),
|
|
86
|
+
scale: {
|
|
87
|
+
x: clamp(finiteNumber(values.scaleX, 1), -8, 8),
|
|
88
|
+
y: clamp(finiteNumber(values.scaleY, 1), -8, 8),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
if (values.fillEnabled !== false && validColor(values.fill)) output.fill = values.fill;
|
|
93
|
+
if (values.strokeColorEnabled !== false && validColor(values.strokeColor ?? values.stroke_color)) {
|
|
94
|
+
output.stroke_color = values.strokeColor ?? values.stroke_color;
|
|
95
|
+
}
|
|
96
|
+
return output;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const shapeEditorValues = (shape, kind) => {
|
|
100
|
+
const transform = shape?.transform || {};
|
|
101
|
+
const translate = vectorValue(transform.translate ?? shape?.translate, { x: 0, y: 0 });
|
|
102
|
+
const scale = vectorValue(transform.scale ?? shape?.scale, { x: 1, y: 1 });
|
|
103
|
+
return {
|
|
104
|
+
kind,
|
|
105
|
+
translateX: translate.x,
|
|
106
|
+
translateY: translate.y,
|
|
107
|
+
rotate: finiteNumber(transform.rotate ?? shape?.rotate ?? shape?.rotation, 0),
|
|
108
|
+
scaleX: scale.x,
|
|
109
|
+
scaleY: scale.y,
|
|
110
|
+
opacity: clamp(finiteNumber(shape?.opacity, 1), 0, 1),
|
|
111
|
+
fill: validColor(shape?.fill) ? shape.fill : "#000000",
|
|
112
|
+
fillEnabled: validColor(shape?.fill),
|
|
113
|
+
strokeColor: validColor(shape?.stroke_color ?? shape?.strokeColor) ? (shape.stroke_color ?? shape.strokeColor) : "#ffffff",
|
|
114
|
+
strokeColorEnabled: validColor(shape?.stroke_color ?? shape?.strokeColor),
|
|
115
|
+
strokeWidth: Math.max(0, finiteNumber(shape?.stroke_width ?? shape?.strokeWidth ?? shape?.stroke, 1)),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const applyShapePatch = (shape, patch) => {
|
|
120
|
+
if (!patch || typeof patch !== "object") return shape;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
...(shape || {}),
|
|
124
|
+
...patch,
|
|
125
|
+
transform: {
|
|
126
|
+
...((shape || {}).transform || {}),
|
|
127
|
+
...(patch.transform || {}),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const vectorValue = (value, fallback) => {
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
return { x: finiteNumber(value[0], fallback.x), y: finiteNumber(value[1], fallback.y) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (value && typeof value === "object") {
|
|
138
|
+
return { x: finiteNumber(value.x, fallback.x), y: finiteNumber(value.y, fallback.y) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const numeric = finiteNumber(value, null);
|
|
142
|
+
return numeric === null ? fallback : { x: numeric, y: numeric };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const normalizeKind = (value) => {
|
|
146
|
+
const kind = String(value || "circle").trim().toLowerCase();
|
|
147
|
+
return SHAPE_KINDS.includes(kind) ? kind : "circle";
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const validColor = (value) => /^#[0-9a-f]{6}$/i.test(String(value || ""));
|
|
151
|
+
|
|
152
|
+
const finiteNumber = (value, fallback) => {
|
|
153
|
+
const numeric = Number(value);
|
|
154
|
+
return Number.isFinite(numeric) ? numeric : fallback;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|