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.
- checksums.yaml +4 -4
- data/README.md +70 -117
- data/docs/.nojekyll +0 -0
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +225 -0
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +494 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +1060 -16
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +157 -3
- data/frontend/src/renderer/layer-manager.js +442 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +666 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +337 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +1280 -23
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +275 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +143 -8
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +391 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/shape.rb +719 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +28 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +461 -0
- metadata +94 -3
- data/docs/GETTING_STARTED.md +0 -105
|
@@ -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);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const DEFAULT_SIZE = 64;
|
|
2
|
+
const HASH_OFFSET = 0x811c9dc5;
|
|
3
|
+
const HASH_PRIME = 0x01000193;
|
|
4
|
+
|
|
5
|
+
export const lineSegmentsFingerprint = (points, {
|
|
6
|
+
width = DEFAULT_SIZE,
|
|
7
|
+
height = DEFAULT_SIZE,
|
|
8
|
+
} = {}) => {
|
|
9
|
+
const pixels = rasterizeLineSegments(points, { width, height });
|
|
10
|
+
let hash = HASH_OFFSET;
|
|
11
|
+
|
|
12
|
+
for (const value of pixels) {
|
|
13
|
+
hash ^= value;
|
|
14
|
+
hash = Math.imul(hash, HASH_PRIME) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return hash.toString(16).padStart(8, "0");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const rasterizeLineSegments = (points, {
|
|
21
|
+
width = DEFAULT_SIZE,
|
|
22
|
+
height = DEFAULT_SIZE,
|
|
23
|
+
} = {}) => {
|
|
24
|
+
const safeWidth = clampInt(width, 8, 512);
|
|
25
|
+
const safeHeight = clampInt(height, 8, 512);
|
|
26
|
+
const pixels = new Uint8Array(safeWidth * safeHeight);
|
|
27
|
+
const input = Array.isArray(points) || ArrayBuffer.isView(points) ? Array.from(points) : [];
|
|
28
|
+
|
|
29
|
+
for (let index = 0; index + 3 < input.length; index += 4) {
|
|
30
|
+
const x1 = normalizedToPixel(input[index], safeWidth);
|
|
31
|
+
const y1 = normalizedToPixel(-input[index + 1], safeHeight);
|
|
32
|
+
const x2 = normalizedToPixel(input[index + 2], safeWidth);
|
|
33
|
+
const y2 = normalizedToPixel(-input[index + 3], safeHeight);
|
|
34
|
+
drawLine(pixels, safeWidth, safeHeight, x1, y1, x2, y2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return pixels;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const drawLine = (pixels, width, height, x1, y1, x2, y2) => {
|
|
41
|
+
const dx = Math.abs(x2 - x1);
|
|
42
|
+
const dy = Math.abs(y2 - y1);
|
|
43
|
+
const steps = Math.max(dx, dy, 1);
|
|
44
|
+
|
|
45
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
46
|
+
const amount = step / steps;
|
|
47
|
+
const x = Math.round(x1 + (x2 - x1) * amount);
|
|
48
|
+
const y = Math.round(y1 + (y2 - y1) * amount);
|
|
49
|
+
if (x < 0 || y < 0 || x >= width || y >= height) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
pixels[y * width + x] = 255;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const normalizedToPixel = (value, size) => {
|
|
57
|
+
const normalized = (clamp(Number(value) || 0, -1, 1) + 1) * 0.5;
|
|
58
|
+
return Math.round(normalized * (size - 1));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const clampInt = (value, min, max) => {
|
|
62
|
+
const numeric = Number(value);
|
|
63
|
+
if (!Number.isFinite(numeric)) return min;
|
|
64
|
+
return Math.round(Math.min(Math.max(numeric, min), max));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
@@ -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
|
+
};
|