mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
* feat(highway_3d): hit-feedback juice — cinematic lighting, strike line, sparks, intensity dial Charrette wave 1 (additive, default-tasteful, all behind settings): - #8 Hit-feedback settings: hitFx (0..1), cinematic, verdictMarks, timingFx, streakFx in BG_DEFAULTS + h3dBgSet* setters + settings.html (intensity slider + cinematic toggle). hitFx=0 → colour verdict only. - #2 Cinematic lighting: ambient 0.85→0.35 + stronger key light when cinematic on, so emissive gems have a dark surround to pop against. Live-toggleable. - #1 Strike line: a glowing bar at the hit line (Z=0) that flashes green on a verified hit / red on a miss, eased from the per-frame verdict alpha. - #3 Hit sparks: a pooled additive Points burst at the gem on a verified hit (deduped one burst per note), scaled by hitFx; disposed on teardown. Staged for wave 2 (after dogfooding): bloom+ACES (#4), colorblind verdict glyphs (#6), early/late timing tint (#5), streak heat + clean-bar (#7), gem scale-punch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): wave 2 — gem scale-punch, streak heat, colorblind verdict marks - #3 (completion) gem scale-punch: the hit gem briefly grows (1 + 0.22·hitFx·alpha), biggest at the strike and easing with the verdict — the per-gem impulse. - #7 streak heat: a renderer-side consecutive-hit counter eases a 0..1 "heat" (plateau at 16) that grows the spark burst + warms the strike-line idle glow; a miss eases it back down. Behind the Streak-feedback toggle. - #6 colorblind verdict marks: a redundant ✓ (hit) / ✗ (miss) glyph on the verdict via the existing 2D label overlay, so the green/red pair isn't the only signal — notably also covers the provider path (where the timing labels don't show). - settings.html: Streak-feedback + Accessible-marks toggles. Deferred: #4 bloom+ACES (needs the Three.js postprocessing addons vendored into core static/vendor/three/ — not present; warrants its own infra change), and #5's timing tint (the early/late ±ms labels already render on the event path; surfacing them on the provider path needs a notedetect verdict field — a cross-plugin item). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): #4 bloom + ACES — vendored Three.js postprocessing, perf-gated The single biggest fidelity lever from the charrette. Core had only three.module.min.js (no postprocessing addons), so this vendors the r170 EffectComposer/RenderPass/UnrealBloomPass/OutputPass + their shader deps into static/vendor/three/addons/, with every `from 'three'` rewritten to the SAME vendored three (../../three.module.min.js) so the addons share the plugin's three instance (a CDN copy would be a second, non-interoperable module). highway_3d wiring: - Lazy-loads the addons only when the new `bloom` setting is on (dynamic import), builds EffectComposer(RenderPass → UnrealBloomPass(strength .65/radius .5/ threshold .82 — high so only emissive gems + the hit flash bloom) → OutputPass). - Render loop uses composer.render() with ACES tone-mapping when bloom is active, else the unchanged direct ren.render() with NoToneMapping (bloom-off = today's look). - Perf-gated: OFF in splitscreen; graceful fallback to direct render if the modules or composer fail; composer.setSize on canvas resize; disposed on teardown. - settings.html: "Glow bloom" toggle (default on). Verified the import chain resolves + renders via a same-origin module-load test (EffectComposer built + a bloom frame rendered, three r170). Charrette status: 7/8 (only #5's early/late timing tint remains — a notedetect verdict-field change, outside the highway). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): #5 early/late timing — colour the hit feedback by timing Surfaces the detector's timing on every hit (the charrette's last item), fully highway-side: notedetect already dispatches the judgment (timingState/timingError) on notedetect:hit/miss, so we carry timingState onto the event mark and tint the hit's spark burst + the ✓ verdict glyph by it — on-time green, early cyan, late amber. Gracefully falls back to green when no timing is known (pure-provider path), so it never invents data. Behind the new "Timing feedback" toggle (default on). Charrette: 8/8 complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq * feat(highway_3d): add a "Hit sparks" on/off toggle (note-hit particles) The on-hit spark burst (the particle effect that fires the instant note_detect confirms a hit) could previously only be removed by dragging Hit-feedback intensity to 0 — which also kills the strike-line flash and the scale-punch. Add a dedicated "Hit sparks" toggle (default on) under 3D Highway settings, in the hit-feedback group beside the intensity slider, that gates ONLY the spark particles; the strike flash and colour verdict are unaffected. Wired the same way as the sibling juice toggles: a `sparks` boolean in BG_DEFAULTS, in _BG_BOOL_KEYS, a window.h3dBgSetSparks setter, the per-instance _sparks state + settings re-read, and a guard on the _sparkBurst spawn. Reuses existing Tailwind utility classes, so assets/plugin.css is unchanged; plugin.json version bumped to 3.28.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(highway_3d): act on tester charrette — strike line, fog readability, AA Addresses the alpha-tester 3D-highway feedback thread via the design panel's recommendations: - Strike line (panel rec 1a): now a HIT-ONLY faint "now" line — flashes green on a confirmed hit, no red miss branch (misses already show at the gem: red wash + ✗). Moved off the bottom edge to the vertical CENTRE of the string field, which was the "incorrectly placed" complaint (it read as the board's lower border and fused with open-string gems on a miss). Added a "Strike line" on/off toggle (`strikeLine`, default on). - Horizon readability (#2): the note gems + their outlines are now fog-exempt (`material.fog = false` on mStr/mGlow/mStrHitOutline/mHitBright/mWhiteOutline/ mMissOutline), so upcoming notes punch through the distance fog and stay legible as they render in — the board, lane, sustains and scenery keep their atmospheric fog, so depth is preserved. - Cinematic lighting softened: cinematic ambient 0.35 -> 0.45 so the dark stage doesn't crush note/fret legibility. - Anti-aliasing under bloom (perf rec): give the bloom EffectComposer a multisampled (WebGL2 MSAA x4) HalfFloat render target. The default target had no `samples`, so bloom-on bypassed MSAA — the "too HD / jagged on Windows, fine on Mac" report (Mac only won via Retina supersampling). This is the highest-value, smallest fix for the jaggies. plugin.json -> 3.29.0. The renderScale quality-oscillation is core (static/highway.js) and will be a separate feedBack PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(highway_3d): remove the strike line; sparks-only hit feedback, subtler Second tester-charrette pass. The strike line (even hit-only/centred from the last pass) was still too distracting/confusing on a hit, so it's removed entirely — strings + fret markers already orient the player, and the hit is fully carried at the gem (bright outline + scale-punch + spark burst) with the timing-coloured ✓/✗ verdict as the knowledge-of-results channel. - Deleted the strike-line mesh, its per-frame update, the `strikeLine` setting (BG_DEFAULTS / _BG_BOOL_KEYS / setter / settings-load), the settings.html toggle, and the now-dead `_strikeLine`/`_ndHitFlash`/`_ndMissFlash` state + their verdict-block feeds. - Made the spark burst subtler now that it's the sole celebration: point size 1.7→1.0·K, opacity 0.95→0.8, burst count (7+13·hitFx)→(4+7·hitFx), radial speed (7+r·20)→(5+r·12)·K, life (0.40+r·0.28)→(0.30+r·0.16)s. - Toggles for Hit sparks and the ✓/✗ verdict marks already exist in settings (kept). Minimal hit-feedback set now: gem bright + subtle spark (celebration) + timing-coloured ✓/✗ (the KR) + ambient streak heat. plugin.json -> 3.30.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(highway_3d): hydrate hit-feedback settings controls from saved state The 7 new juice controls (Hit sparks, Cinematic, Streak, Verdict marks, Bloom, Timing, Hit-feedback intensity) were hard-coded to their default markup and never read back from localStorage when the settings panel reopened — so a saved non-default (e.g. Hit sparks off) showed as the default (checked) even though the renderer correctly honored it. The sibling controls in the same panel were already hydrated; this restores that pattern for the new ones. Reads h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on, hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' coercion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
1817 lines
109 KiB
HTML
1817 lines
109 KiB
HTML
<div role="group" aria-labelledby="h3d-strcol-heading">
|
||
<h3 id="h3d-strcol-heading" class="text-sm font-medium text-gray-400 mb-2">Highway String Colors</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
Pick a color per named string for both the 2D and 3D highways (string
|
||
lines and gems). Colors follow the string by name, so Low E keeps its
|
||
color whether you're on a 6-string guitar, a 4-string bass, or a 7/8-string
|
||
(where the extra low strings use the Low B / Low F# slots).
|
||
</p>
|
||
<div class="flex gap-2 mb-3">
|
||
<select id="hwc-theme-select" onchange="window.hwcOnSelectTheme?.(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<option value="">Default colors</option>
|
||
</select>
|
||
<button type="button" onclick="window.hwcSaveTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Save as…</button>
|
||
<button type="button" onclick="window.hwcDeleteTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-400">Delete</button>
|
||
</div>
|
||
<!-- One-click presets: apply a whole named palette to every string at once.
|
||
Buttons are generated from window.feedBack.highwayColors.presets so the
|
||
list stays in sync with core (app.js HWC_PRESETS). Each calls
|
||
applyPreset(id), which persists + applies to both highways and refreshes
|
||
the pickers below. Older core without the presets API leaves this empty. -->
|
||
<div class="mb-3">
|
||
<span class="text-xs text-gray-500 mb-1 block">Quick presets</span>
|
||
<div id="hwc-presets" class="flex flex-wrap gap-2"></div>
|
||
</div>
|
||
<div id="hwc-pickers" class="mb-3" style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;"></div>
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<button type="button" onclick="window.hwcReset?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Reset to defaults</button>
|
||
<button type="button" onclick="window.hwcCopyShare?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Copy share code</button>
|
||
<span id="hwc-status" class="text-xs text-gray-500"></span>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<input type="text" id="hwc-import-code" placeholder="Paste a share code…"
|
||
aria-label="Import highway color share code"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<button type="button" onclick="window.hwcImport?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Import</button>
|
||
</div>
|
||
<script>
|
||
// The String Colors UI markup lives here (in the 3D Highway plugin), but
|
||
// the color manager (translation table, storage, share codec, apply to
|
||
// BOTH highways) lives in core app.js. Populate the pickers + saved-theme
|
||
// dropdown once this panel is injected. Guarded so an older core that
|
||
// lacks the manager simply renders an empty (inert) panel.
|
||
(function () {
|
||
if (typeof window.hwcInitSettingsUI === 'function') {
|
||
try { window.hwcInitSettingsUI(); } catch (e) { console.warn('[3D-Hwy] hwcInitSettingsUI failed', e); }
|
||
}
|
||
// Render the one-click preset buttons from core's preset list.
|
||
try {
|
||
const api = window.feedBack && window.feedBack.highwayColors;
|
||
const host = document.getElementById('hwc-presets');
|
||
if (api && Array.isArray(api.presets) && host) {
|
||
host.innerHTML = '';
|
||
// Slot order low → high for the preview swatch (bass-side first).
|
||
const order = ['lowE', 'A', 'D', 'G', 'B', 'highE'];
|
||
for (const p of api.presets) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'flex items-center gap-2 bg-dark-700 border border-gray-800 rounded-lg px-2 py-1 text-xs text-gray-300';
|
||
const sw = document.createElement('span');
|
||
const stops = order.map((k) => p.colors[k]).filter(Boolean).join(',');
|
||
sw.style.cssText = 'width:2.5rem;height:0.85rem;border-radius:3px;border:1px solid #0006;'
|
||
+ 'background:linear-gradient(90deg,' + stops + ');';
|
||
btn.appendChild(sw);
|
||
const txt = document.createElement('span');
|
||
txt.textContent = p.label;
|
||
btn.appendChild(txt);
|
||
btn.addEventListener('click', function () {
|
||
try { api.applyPreset(p.id); } catch (e) { console.warn('[3D-Hwy] applyPreset failed', e); }
|
||
});
|
||
host.appendChild(btn);
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[3D-Hwy] preset render failed', e); }
|
||
})();
|
||
</script>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-fretboard-heading" class="mt-6">
|
||
<h3 id="h3d-fretboard-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Fretboard</h3>
|
||
|
||
<!-- Fret spacing -->
|
||
<div class="mt-3">
|
||
<label for="h3d-fret-spacing" class="text-xs font-medium text-gray-400 mb-1 block">Fret spacing</label>
|
||
<select id="h3d-fret-spacing"
|
||
onchange="window.h3dSetFretSpacing && window.h3dSetFretSpacing(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<option value="uniform">Uniform (equal width per fret)</option>
|
||
<option value="logarithmic">Instrument (logarithmic — decreasing toward bridge)</option>
|
||
</select>
|
||
<p class="text-xs text-gray-500 mt-1">Applies to the 3D highway immediately.</p>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const sel = document.getElementById('h3d-fret-spacing');
|
||
if (sel) {
|
||
try {
|
||
sel.value = localStorage.getItem('highway_3d.fretSpacing') === 'logarithmic' ? 'logarithmic' : 'uniform';
|
||
} catch (_) {
|
||
sel.value = 'uniform';
|
||
}
|
||
}
|
||
})();
|
||
</script>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-bg-heading" class="mt-6">
|
||
<h3 id="h3d-bg-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Background</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
Subtle ambient animation in the distant fog area beyond the highway.
|
||
Settings here apply to all 3D Highway panels by default; per-panel
|
||
overrides can be set later (issue #13 follow-up).
|
||
</p>
|
||
|
||
<!-- Style -->
|
||
<div class="mt-3">
|
||
<label for="h3d-bg-style" class="text-xs font-medium text-gray-400 mb-1 block">Style</label>
|
||
<select id="h3d-bg-style"
|
||
onchange="window.h3dBgSetStyle && window.h3dBgSetStyle(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<option value="off">Off</option>
|
||
<option value="particles" selected>Particles (drifting)</option>
|
||
<option value="silhouettes">Silhouettes (parallax)</option>
|
||
<option value="lights">Lights (stage glows)</option>
|
||
<option value="geometric">Geometric (rotating shapes)</option>
|
||
<option value="butterchurn">Butterchurn (visualizer)</option>
|
||
<option value="image" disabled>Custom image (none uploaded)</option>
|
||
<option value="video" disabled>Custom video (none uploaded)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Scene colors — TWO independent axes sharing one palette family:
|
||
Background (clear + fog) and Highway (board surface + lit lane). Same
|
||
id-set in both, so any background can mix with any highway; picking the
|
||
same in both gives the original "matched" look. "Default" is the
|
||
original look on both. -->
|
||
<div class="mt-3">
|
||
<label for="h3d-bg-theme" class="text-xs font-medium text-gray-400 mb-1 block">Background</label>
|
||
<!-- Options are populated from SCENE_THEMES (the single id+label source)
|
||
in the hydration script below, so the two dropdowns can't drift. -->
|
||
<select id="h3d-bg-theme"
|
||
onchange="window.h3dBgSetBgTheme && window.h3dBgSetBgTheme(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
</select>
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
Tints the background + distance fog. Applies to the 3D highway immediately.
|
||
</p>
|
||
</div>
|
||
<div class="mt-3">
|
||
<label for="h3d-hw-theme" class="text-xs font-medium text-gray-400 mb-1 block">Highway (surface + lane)</label>
|
||
<!-- Options populated from SCENE_THEMES (see below), same source as the
|
||
Background dropdown so the two stay in lockstep. -->
|
||
<select id="h3d-hw-theme"
|
||
onchange="window.h3dBgSetHwTheme && window.h3dBgSetHwTheme(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
</select>
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
Tints the fretboard surface + the lit lane. Applies to the 3D highway immediately.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Butterchurn (visualizer) options. These drive the 'butterchurn'
|
||
Background style. They used to live in a floating in-canvas panel; the
|
||
on/off + slider controls now live here in the standard settings UI.
|
||
The live preset browser (pick / favorite / ban / cycle) stays in-canvas.
|
||
Persisted in the 'viz3d_settings' localStorage blob (shared with the
|
||
in-canvas preset controls); window.h3dBcApplySettings() pushes changes
|
||
to a mounted highway live. -->
|
||
<div id="h3d-bc-section" class="mt-4">
|
||
<h3 class="text-sm font-medium text-gray-400 mb-2">Butterchurn (visualizer)</h3>
|
||
<p class="text-xs text-gray-500 mb-2">Active when Background style is set to <em>Butterchurn (visualizer)</em>.</p>
|
||
<div class="flex items-center gap-3 mt-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-bc-enabled" class="accent-accent"> Background on
|
||
</label>
|
||
</div>
|
||
<div class="mt-3">
|
||
<label for="h3d-bc-opacity" class="text-xs font-medium text-gray-400 mb-1 block">Opacity: <span id="h3d-bc-opacity-label">100%</span></label>
|
||
<input type="range" id="h3d-bc-opacity" min="0" max="100" class="w-full accent-accent">
|
||
</div>
|
||
<div class="flex items-center gap-3 mt-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-bc-laneDim" class="accent-accent"> Dim behind lane
|
||
</label>
|
||
</div>
|
||
<div class="mt-3">
|
||
<label for="h3d-bc-laneDimStrength" class="text-xs font-medium text-gray-400 mb-1 block">Dim strength</label>
|
||
<input type="range" id="h3d-bc-laneDimStrength" min="0" max="100" class="w-full accent-accent">
|
||
</div>
|
||
<div class="flex items-center gap-3 mt-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-bc-chartAccents" class="accent-accent"> Chart accents
|
||
</label>
|
||
</div>
|
||
<div class="mt-3">
|
||
<label for="h3d-bc-chartStrength" class="text-xs font-medium text-gray-400 mb-1 block">Chart strength</label>
|
||
<input type="range" id="h3d-bc-chartStrength" min="0" max="200" class="w-full accent-accent">
|
||
</div>
|
||
<div class="flex items-center gap-3 mt-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-bc-colorTint" class="accent-accent"> Color tint
|
||
</label>
|
||
</div>
|
||
<div class="mt-3">
|
||
<label for="h3d-bc-tintStrength" class="text-xs font-medium text-gray-400 mb-1 block">Tint strength</label>
|
||
<input type="range" id="h3d-bc-tintStrength" min="0" max="100" class="w-full accent-accent">
|
||
</div>
|
||
<div class="mt-3">
|
||
<label for="h3d-bc-guitarGain" class="text-xs font-medium text-gray-400 mb-1 block">Guitar gain: <span id="h3d-bc-guitarGain-label">×6</span></label>
|
||
<input type="range" id="h3d-bc-guitarGain" min="1" max="12" step="0.5" class="w-full accent-accent">
|
||
</div>
|
||
<div class="mt-3">
|
||
<label for="h3d-bc-songGain" class="text-xs font-medium text-gray-400 mb-1 block">Song gain: <span id="h3d-bc-songGain-label">×1.8</span></label>
|
||
<input type="range" id="h3d-bc-songGain" min="0" max="5" step="0.1" class="w-full accent-accent">
|
||
</div>
|
||
</div>
|
||
<script>
|
||
// Self-contained Butterchurn settings wiring. Reads/writes the same
|
||
// 'viz3d_settings' blob the in-canvas preset controls use (read-modify-
|
||
// write preserves cyclePool/hold and the favorites/bans lists), then
|
||
// calls window.h3dBcApplySettings() so a mounted highway applies the
|
||
// change live. Guarded so it no-ops if the section isn't present.
|
||
(function () {
|
||
const BC_LS = 'viz3d_settings';
|
||
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8 };
|
||
function readBlob() { let s = {}; try { s = JSON.parse(localStorage.getItem(BC_LS) || '{}') || {}; } catch (e) {} return s; }
|
||
function patch(p) {
|
||
const s = readBlob();
|
||
Object.assign(s, p);
|
||
try { localStorage.setItem(BC_LS, JSON.stringify(s)); } catch (e) {}
|
||
try { if (typeof window.h3dBcApplySettings === 'function') window.h3dBcApplySettings(); } catch (e) {}
|
||
}
|
||
const s = Object.assign({}, BC_DEFAULTS, readBlob());
|
||
const $ = (id) => document.getElementById(id);
|
||
const en = $('h3d-bc-enabled');
|
||
if (!en) return;
|
||
const op = $('h3d-bc-opacity'), opv = $('h3d-bc-opacity-label');
|
||
const ld = $('h3d-bc-laneDim'), lds = $('h3d-bc-laneDimStrength');
|
||
const ca = $('h3d-bc-chartAccents'), cs = $('h3d-bc-chartStrength');
|
||
const ct = $('h3d-bc-colorTint'), ts = $('h3d-bc-tintStrength');
|
||
const gg = $('h3d-bc-guitarGain'), ggv = $('h3d-bc-guitarGain-label');
|
||
const sg = $('h3d-bc-songGain'), sgv = $('h3d-bc-songGain-label');
|
||
// Hydrate from saved settings.
|
||
en.checked = !!s.enabled;
|
||
op.value = Math.round((s.opacity != null ? s.opacity : 1) * 100); if (opv) opv.textContent = op.value + '%';
|
||
ld.checked = !!s.laneDim; lds.value = Math.round((s.laneDimStrength != null ? s.laneDimStrength : 0.45) * 100);
|
||
ca.checked = !!s.chartAccents; cs.value = Math.round((s.chartStrength != null ? s.chartStrength : 1) * 100);
|
||
ct.checked = !!s.colorTint; ts.value = Math.round((s.tintStrength != null ? s.tintStrength : 0.65) * 100);
|
||
gg.value = s.guitarGain != null ? s.guitarGain : 6; if (ggv) ggv.textContent = '×' + gg.value;
|
||
sg.value = s.songGain != null ? s.songGain : 1.8; if (sgv) sgv.textContent = '×' + sg.value;
|
||
// Wire changes (read-modify-write + live apply).
|
||
en.addEventListener('change', () => patch({ enabled: en.checked }));
|
||
op.addEventListener('input', () => { if (opv) opv.textContent = op.value + '%'; patch({ opacity: op.value / 100 }); });
|
||
ld.addEventListener('change', () => patch({ laneDim: ld.checked }));
|
||
lds.addEventListener('input', () => patch({ laneDimStrength: lds.value / 100 }));
|
||
ca.addEventListener('change', () => patch({ chartAccents: ca.checked }));
|
||
cs.addEventListener('input', () => patch({ chartStrength: cs.value / 100 }));
|
||
ct.addEventListener('change', () => patch({ colorTint: ct.checked }));
|
||
ts.addEventListener('input', () => patch({ tintStrength: ts.value / 100 }));
|
||
gg.addEventListener('input', () => { if (ggv) ggv.textContent = '×' + gg.value; patch({ guitarGain: parseFloat(gg.value) }); });
|
||
sg.addEventListener('input', () => { if (sgv) sgv.textContent = '×' + sg.value; patch({ songGain: parseFloat(sg.value) }); });
|
||
})();
|
||
</script>
|
||
|
||
<!-- Intensity -->
|
||
<div class="mt-3">
|
||
<label for="h3d-bg-intensity" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Intensity: <span id="h3d-bg-intensity-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-bg-intensity" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-bg-intensity-label').textContent = parseFloat(this.value).toFixed(2);"
|
||
onchange="window.h3dBgSetIntensity && window.h3dBgSetIntensity(this.value)"
|
||
class="w-full accent-accent">
|
||
</div>
|
||
|
||
<!-- Audio reactivity -->
|
||
<div class="flex items-center gap-3 mt-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-bg-reactive" checked
|
||
onchange="window.h3dBgSetReactive && window.h3dBgSetReactive(this.checked)"
|
||
class="accent-accent">
|
||
React to audio
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Custom image upload (#19). Persisted in localStorage under
|
||
h3d_bg_customImageDataUrl (the base64 data URL that drives
|
||
the texture) and h3d_bg_customImageName (display-only
|
||
filename). Renders as the `image` bg style; the picker
|
||
option above auto-enables once an asset is uploaded. -->
|
||
<div class="mt-3">
|
||
<label for="h3d-bg-custom-image-file" class="text-xs font-medium text-gray-400 mb-1 block">Custom image</label>
|
||
<p class="text-xs text-gray-500 mb-2">
|
||
Upload a JPG/PNG/WebP (≤1.5 MB). Renders as a slowly
|
||
drifting backdrop in the fog band when the style above is set
|
||
to <em>Custom image</em>.
|
||
</p>
|
||
<div class="flex items-center gap-2">
|
||
<input type="file" id="h3d-bg-custom-image-file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
class="text-xs text-gray-300 file:mr-2 file:px-2 file:py-1 file:rounded file:border-0 file:bg-dark-600 file:text-gray-300">
|
||
<button type="button" id="h3d-bg-custom-image-clear"
|
||
class="text-xs px-2 py-1 rounded bg-dark-700 border border-gray-800 text-gray-400 hover:text-gray-200">Clear</button>
|
||
</div>
|
||
<p id="h3d-bg-custom-image-name" class="text-xs text-gray-500 mt-1"></p>
|
||
<p id="h3d-bg-custom-image-error" class="text-xs text-red-400 mt-1 hidden"></p>
|
||
</div>
|
||
|
||
<!-- Custom video upload (#19 follow-up). Bytes live on disk via
|
||
the plugin's routes.py upload route; localStorage holds only
|
||
the server-side filename (h3d_bg_customVideoName). Renders as
|
||
the `video` bg style; the picker option above auto-enables
|
||
once an asset is uploaded. -->
|
||
<div class="mt-3">
|
||
<label for="h3d-bg-custom-video-file" class="text-xs font-medium text-gray-400 mb-1 block">Custom video</label>
|
||
<p class="text-xs text-gray-500 mb-2">
|
||
Upload an MP4 or WebM (≤50 MB). Plays muted, looped
|
||
in the fog band when the style above is set to
|
||
<em>Custom video</em>. Bytes stay on the feedBack server,
|
||
not in the browser.
|
||
</p>
|
||
<div class="flex items-center gap-2">
|
||
<input type="file" id="h3d-bg-custom-video-file"
|
||
accept="video/mp4,video/webm"
|
||
class="text-xs text-gray-300 file:mr-2 file:px-2 file:py-1 file:rounded file:border-0 file:bg-dark-600 file:text-gray-300">
|
||
<button type="button" id="h3d-bg-custom-video-clear"
|
||
class="text-xs px-2 py-1 rounded bg-dark-700 border border-gray-800 text-gray-400 hover:text-gray-200">Clear</button>
|
||
</div>
|
||
<p id="h3d-bg-custom-video-name" class="text-xs text-gray-500 mt-1"></p>
|
||
<p id="h3d-bg-custom-video-error" class="text-xs text-red-400 mt-1 hidden"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-camera-heading" class="mt-6">
|
||
<h3 id="h3d-camera-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Camera</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
The camera tracks your upcoming notes — panning along the
|
||
fretboard, zooming for wide chords, and tilting to keep the
|
||
playing area centred. Defaults are tuned for general use;
|
||
open <em>Advanced camera settings</em> below to fine-tune
|
||
each axis independently, lock the camera to first position,
|
||
or scale on-screen text. Changes apply immediately while
|
||
playing — no reload required.
|
||
</p>
|
||
|
||
<div class="mt-3">
|
||
<label for="h3d-camera-mode" class="text-xs font-medium text-gray-400 mb-1 block">Camera view</label>
|
||
<select id="h3d-camera-mode"
|
||
onchange="window.h3dBgSetCameraMode && window.h3dBgSetCameraMode(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<option value="lookahead">Wide ahead (default)</option>
|
||
<option value="steady">Steady & close-in</option>
|
||
</select>
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
<em>Wide ahead</em> shows more of the neck coming up and eases the framing forward.
|
||
<em>Steady & close-in</em> keeps a calmer view centred on the notes right in front of you.
|
||
</p>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-fret-look-heading" class="mt-5">
|
||
<h3 id="h3d-fret-look-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Nut & headstock</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
The 3D neck shows a nut, headstock, and a pitch label per string (from the chart tuning).
|
||
Turn pieces off or pick colors to match your taste.
|
||
</p>
|
||
<div class="flex flex-col gap-3 mt-2">
|
||
<label class="flex items-start gap-2 text-sm text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-nut-headstock-visible" checked class="mt-1 accent-accent"
|
||
onchange="window.h3dBgSetNutHeadstockVisible && window.h3dBgSetNutHeadstockVisible(this.checked)">
|
||
<span>Show nut & headstock</span>
|
||
</label>
|
||
<label class="flex items-start gap-2 text-sm text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-tuning-labels-visible" checked class="mt-1 accent-accent"
|
||
onchange="window.h3dBgSetTuningLabelsVisible && window.h3dBgSetTuningLabelsVisible(this.checked)">
|
||
<span>Show open-string note name per string</span>
|
||
</label>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-1">
|
||
<div>
|
||
<label for="h3d-nut-color" class="text-xs font-medium text-gray-400 mb-1 block">Nut color</label>
|
||
<input type="color" id="h3d-nut-color" value="#f5f3f0"
|
||
onchange="window.h3dBgSetNutColor && window.h3dBgSetNutColor(this.value)"
|
||
class="h-9 w-full max-w-[12rem] cursor-pointer rounded border border-gray-800 bg-dark-700 p-0.5">
|
||
</div>
|
||
<div>
|
||
<label for="h3d-headstock-color" class="text-xs font-medium text-gray-400 mb-1 block">Headstock color</label>
|
||
<input type="color" id="h3d-headstock-color" value="#d4b48a"
|
||
onchange="window.h3dBgSetHeadstockColor && window.h3dBgSetHeadstockColor(this.value)"
|
||
class="h-9 w-full max-w-[12rem] cursor-pointer rounded border border-gray-800 bg-dark-700 p-0.5">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<details class="mt-4 rounded-lg border border-gray-800 bg-dark-700/40">
|
||
<summary class="cursor-pointer select-none px-3 py-2 text-xs font-medium text-gray-400 hover:text-gray-200">
|
||
Advanced camera settings
|
||
</summary>
|
||
<div class="px-3 pb-3 pt-1">
|
||
<p class="text-[10px] text-gray-500 mb-3">
|
||
Per-axis tuning, a hard lock for songs that mostly stay in
|
||
first position, and a global text-size dial. Most players
|
||
don't need to touch these.
|
||
</p>
|
||
|
||
<div class="mt-3">
|
||
<label for="h3d-camera-smoothing" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Camera smoothing (X-pan): <span id="h3d-camera-smoothing-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-camera-smoothing" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-camera-smoothing-label').textContent = parseFloat(this.value).toFixed(2);"
|
||
onchange="window.h3dBgSetCameraSmoothing && window.h3dBgSetCameraSmoothing(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
How aggressively the camera chases your upcoming notes
|
||
side-to-side along the fretboard. Lower values
|
||
(toward <em>Twitchy</em>) make the camera respond
|
||
quickly to every shift, even small ones — useful
|
||
if you want the view tightly centred on the next
|
||
note at all times. Higher values (toward <em>Calm</em>)
|
||
ignore small intra-position movement and only pan
|
||
when the upcoming notes really shift to a new
|
||
region of the neck (e.g. an open-position riff
|
||
into a 12th-fret solo). Large position changes
|
||
still pan smoothly at any setting.
|
||
</p>
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1">
|
||
<span>Twitchy</span>
|
||
<span>Calm</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<label for="h3d-zoom-smoothing" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Zoom smoothing: <span id="h3d-zoom-smoothing-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-zoom-smoothing" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-zoom-smoothing-label').textContent = parseFloat(this.value).toFixed(2);"
|
||
onchange="window.h3dBgSetZoomSmoothing && window.h3dBgSetZoomSmoothing(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
Calmness of the camera pulling back for wide chords. Calm settings
|
||
ignore short bursts of high-fret notes that would otherwise pop the
|
||
camera back and forward.
|
||
</p>
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1">
|
||
<span>Twitchy</span>
|
||
<span>Calm</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<label for="h3d-tilt-smoothing" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Tilt smoothing: <span id="h3d-tilt-smoothing-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-tilt-smoothing" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-tilt-smoothing-label').textContent = parseFloat(this.value).toFixed(2);"
|
||
onchange="window.h3dBgSetTiltSmoothing && window.h3dBgSetTiltSmoothing(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
How aggressively the camera re-frames vertically when the fretboard
|
||
drifts. Calm = let small drift ride; Twitchy = re-centre often.
|
||
</p>
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1">
|
||
<span>Twitchy</span>
|
||
<span>Calm</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 flex items-start gap-2">
|
||
<input type="checkbox" id="h3d-camera-lock-low" class="mt-1 accent-accent"
|
||
onchange="window.h3dBgSetCameraLockLow && window.h3dBgSetCameraLockLow(this.checked)">
|
||
<label for="h3d-camera-lock-low" class="text-xs text-gray-300 cursor-pointer">
|
||
<span class="font-medium">Lock camera at frets 1–12.</span>
|
||
<span class="block text-[10px] text-gray-500 mt-1">
|
||
When enabled, the camera stops moving as long as all upcoming
|
||
notes are at fret 12 or below. If a note higher than fret 12
|
||
comes down the highway, the camera widens to include it and
|
||
returns to the locked view once it's gone. Overrides the
|
||
X-pan and zoom smoothing above while engaged; tilt smoothing
|
||
still applies.
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="mt-3 ml-6">
|
||
<label for="h3d-camera-lock-zoom" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Locked zoom: <span id="h3d-camera-lock-zoom-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-camera-lock-zoom" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-camera-lock-zoom-label').textContent = parseFloat(this.value).toFixed(2);"
|
||
onchange="window.h3dBgSetCameraLockZoom && window.h3dBgSetCameraLockZoom(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
Adjusts the camera distance while the lock is engaged. Drag toward
|
||
<em>In</em> to bring the fretboard closer (notes appear larger);
|
||
toward <em>Out</em> to fit more onscreen. Has no effect when the
|
||
lock is off.
|
||
</p>
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1">
|
||
<span>In</span>
|
||
<span>Out</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label for="h3d-text-size" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Text size: <span id="h3d-text-size-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-text-size" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-text-size-label').textContent = parseFloat(this.value).toFixed(2);"
|
||
onchange="window.h3dBgSetTextSize && window.h3dBgSetTextSize(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
Scales every text sprite in the scene — chord names, fret
|
||
numbers, technique markers (bend / slide / H/P/T), section
|
||
banners. Default 0.50 = 1.0× (no change). 0 = 0.5×, 1 = 1.5×.
|
||
</p>
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1">
|
||
<span>Smaller</span>
|
||
<span>Larger</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- FPS counter -->
|
||
<div class="mt-4 flex items-start gap-2">
|
||
<input type="checkbox" id="h3d-fps-visible" class="mt-1 accent-accent"
|
||
onchange="window.h3dBgSetFpsVisible && window.h3dBgSetFpsVisible(this.checked)">
|
||
<label for="h3d-fps-visible" class="text-xs text-gray-300 cursor-pointer">
|
||
<span class="font-medium">Show FPS counter.</span>
|
||
<span class="block text-[10px] text-gray-500 mt-1">
|
||
Displays a live frame-rate readout in the top-right corner of the
|
||
highway. Useful for diagnosing performance issues.
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label for="h3d-vibrancy" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Vibrancy: <span id="h3d-vibrancy-label">0.85</span>
|
||
</label>
|
||
<input type="range" id="h3d-vibrancy" min="0" max="1" step="0.05" value="0.85"
|
||
oninput="document.getElementById('h3d-vibrancy-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetVibrancy && window.h3dBgSetVibrancy(this.value)"
|
||
onchange="window.h3dBgSetVibrancy && window.h3dBgSetVibrancy(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
How vivid idle strings, note bodies, and sustains read against
|
||
the dark scene. <em>Pastel</em> matches the original washed-out
|
||
look (low opacity, hit notes wash toward white); <em>Vivid</em>
|
||
keeps strings/notes solid and lets each string's colour come
|
||
through cleanly even when the note hits.
|
||
</p>
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1">
|
||
<span>Pastel</span>
|
||
<span>Vivid</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label for="h3d-glow" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Glow: <span id="h3d-glow-label">0.25</span>
|
||
</label>
|
||
<input type="range" id="h3d-glow" min="0" max="1" step="0.05" value="0.25"
|
||
oninput="document.getElementById('h3d-glow-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetGlow && window.h3dBgSetGlow(this.value)"
|
||
onchange="window.h3dBgSetGlow && window.h3dBgSetGlow(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
How much halo / bloom builds up around hit notes, sustain
|
||
outlines, technique markers, and the projection ghost layer.
|
||
<em>Off</em> kills emissive everywhere (flat-shaded look,
|
||
projection ghost layer hidden); <em>Glowy</em> matches the
|
||
original bright look. Anticipation pulse on idle strings is
|
||
silenced at <em>Off</em> too.
|
||
</p>
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1">
|
||
<span>Off</span>
|
||
<span>Glowy</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<label for="h3d-hitfx" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Hit feedback intensity: <span id="h3d-hitfx-label">0.70</span>
|
||
</label>
|
||
<input type="range" id="h3d-hitfx" min="0" max="1" step="0.05" value="0.70"
|
||
oninput="document.getElementById('h3d-hitfx-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
|
||
onchange="window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
|
||
class="w-full accent-accent">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
How much "juice" a nailed note gets — the strike-line flash and the
|
||
spark burst at the hit line. <em>0</em> = colour verdict only (no sparks).
|
||
</p>
|
||
</div>
|
||
|
||
<div class="mt-3 flex items-start justify-between gap-3">
|
||
<label for="h3d-sparks" class="text-xs font-medium text-gray-400">
|
||
Hit sparks
|
||
<span class="block text-[10px] text-gray-500 font-normal">The particle burst that pops off a note the instant it's detected as a hit. Turn off for a calmer highway — the strike-line flash and colour verdict stay.</span>
|
||
</label>
|
||
<input type="checkbox" id="h3d-sparks" checked
|
||
onchange="window.h3dBgSetSparks && window.h3dBgSetSparks(this.checked)"
|
||
class="accent-accent mt-0.5">
|
||
</div>
|
||
|
||
<div class="mt-3 flex items-start justify-between gap-3">
|
||
<label for="h3d-cinematic" class="text-xs font-medium text-gray-400">
|
||
Cinematic lighting
|
||
<span class="block text-[10px] text-gray-500 font-normal">Darker stage so the glowing notes pop against it. Turn off for the brighter, flatter look.</span>
|
||
</label>
|
||
<input type="checkbox" id="h3d-cinematic" checked
|
||
onchange="window.h3dBgSetCinematic && window.h3dBgSetCinematic(this.checked)"
|
||
class="accent-accent mt-0.5">
|
||
</div>
|
||
|
||
<div class="mt-3 flex items-start justify-between gap-3">
|
||
<label for="h3d-streakfx" class="text-xs font-medium text-gray-400">
|
||
Streak feedback
|
||
<span class="block text-[10px] text-gray-500 font-normal">A clean run quietly "heats up" — bigger sparks the longer you stay accurate. Eases back on a miss.</span>
|
||
</label>
|
||
<input type="checkbox" id="h3d-streakfx" checked
|
||
onchange="window.h3dBgSetStreakFx && window.h3dBgSetStreakFx(this.checked)"
|
||
class="accent-accent mt-0.5">
|
||
</div>
|
||
|
||
<div class="mt-3 flex items-start justify-between gap-3">
|
||
<label for="h3d-verdictmarks" class="text-xs font-medium text-gray-400">
|
||
Accessible verdict marks (✓ / ✗)
|
||
<span class="block text-[10px] text-gray-500 font-normal">Adds a shape mark to each hit/miss so the result doesn't rely on the green/red colour pair alone.</span>
|
||
</label>
|
||
<input type="checkbox" id="h3d-verdictmarks" checked
|
||
onchange="window.h3dBgSetVerdictMarks && window.h3dBgSetVerdictMarks(this.checked)"
|
||
class="accent-accent mt-0.5">
|
||
</div>
|
||
|
||
<div class="mt-3 flex items-start justify-between gap-3">
|
||
<label for="h3d-bloom" class="text-xs font-medium text-gray-400">
|
||
Glow bloom
|
||
<span class="block text-[10px] text-gray-500 font-normal">Real light-bleed around the glowing notes and hit flash (higher fidelity). Turns itself off in split-screen. If your machine struggles, turn this off first.</span>
|
||
</label>
|
||
<input type="checkbox" id="h3d-bloom" checked
|
||
onchange="window.h3dBgSetBloom && window.h3dBgSetBloom(this.checked)"
|
||
class="accent-accent mt-0.5">
|
||
</div>
|
||
|
||
<div class="mt-3 flex items-start justify-between gap-3">
|
||
<label for="h3d-timingfx" class="text-xs font-medium text-gray-400">
|
||
Timing feedback
|
||
<span class="block text-[10px] text-gray-500 font-normal">Colours a hit by your timing — on-time green, a touch <span style="color:#35d6ff">early (cyan)</span> or <span style="color:#ffb84d">late (amber)</span> — so you can feel where you sit in the beat.</span>
|
||
</label>
|
||
<input type="checkbox" id="h3d-timingfx" checked
|
||
onchange="window.h3dBgSetTimingFx && window.h3dBgSetTimingFx(this.checked)"
|
||
class="accent-accent mt-0.5">
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-notes-heading" class="mt-6">
|
||
<h3 id="h3d-notes-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Notes</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
Per-note display options.
|
||
</p>
|
||
|
||
<!-- Fret digits on board ghost (#12) — not on flying notes -->
|
||
<div class="mt-3 space-y-2">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-show-fret-on-note" checked
|
||
class="accent-accent">
|
||
Show fret numbers on fretboard ghost
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 ml-6">
|
||
Numbers appear on the hollow preview on the fretboard only (not on gems coming down the highway).
|
||
</p>
|
||
<div id="h3d-fret-ghost-scope-wrap" class="ml-6 mt-2 space-y-2 border-l border-gray-700 pl-3">
|
||
<p class="text-xs font-medium text-gray-400">Where to show</p>
|
||
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
||
<input type="radio" name="h3d-fret-ghost-scope" id="h3d-fret-ghost-scope-chords" value="chords"
|
||
class="accent-accent">
|
||
In chords & arpeggios (chord-hand events only)
|
||
</label>
|
||
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
|
||
<input type="radio" name="h3d-fret-ghost-scope" id="h3d-fret-ghost-scope-all" value="all"
|
||
class="accent-accent">
|
||
All notes
|
||
</label>
|
||
<p class="text-[10px] text-gray-500">
|
||
This mode uses chord-hand events only. Single-note lines (including arpeggios charted as separate notes) need <em>All notes</em>.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Slide direction arrow — three independent ›/‹ markers showing which
|
||
way a slide is headed. -->
|
||
<div class="mt-4">
|
||
<label class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-slide-arrow-approach-visible" checked
|
||
onchange="window.h3dBgSetSlideArrowApproachVisible && window.h3dBgSetSlideArrowApproachVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show slide direction arrow on the note
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 mt-1 ml-6">
|
||
A small ›/‹ arrow beside a sliding note's gem, pointing toward
|
||
the slide's destination fret.
|
||
</p>
|
||
</div>
|
||
<div class="mt-4">
|
||
<label class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-slide-arrow-neck-visible" checked
|
||
onchange="window.h3dBgSetSlideArrowNeckVisible && window.h3dBgSetSlideArrowNeckVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show slide direction arrow on the neck preview
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 mt-1 ml-6">
|
||
A preview of the same arrow on the fretboard, fading in ahead
|
||
of time at the slide's starting fret.
|
||
</p>
|
||
</div>
|
||
<div class="mt-4">
|
||
<label class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-slide-arrow-chain-preview-visible" checked
|
||
onchange="window.h3dBgSetSlideArrowChainPreviewVisible && window.h3dBgSetSlideArrowChainPreviewVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show early arrow for slide-into-slide notes
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 mt-1 ml-6">
|
||
If a note slides somewhere and then immediately slides again
|
||
from there, show that second arrow early — while the first
|
||
slide is still playing — instead of waiting.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-sections-heading" class="mt-6">
|
||
<h3 id="h3d-sections-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Sections</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
Surfaces upcoming song sections (Verse, Chorus, Bridge, …) so you can
|
||
anticipate what's coming. The "Up Next" card is a fixed 2D overlay
|
||
with a live countdown.
|
||
</p>
|
||
|
||
<!-- Show "Up Next" card -->
|
||
<div class="flex items-center gap-3 mt-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-section-hud-visible"
|
||
onchange="window.h3dBgSetSectionHudVisible && window.h3dBgSetSectionHudVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show "Up Next" section card
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Position -->
|
||
<div class="mt-3">
|
||
<label for="h3d-section-hud-position" class="text-xs font-medium text-gray-400 mb-1 block">Position</label>
|
||
<select id="h3d-section-hud-position"
|
||
onchange="window.h3dBgSetSectionHudPosition && window.h3dBgSetSectionHudPosition(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<option value="tl">Top-left</option>
|
||
<option value="tr" selected>Top-right</option>
|
||
<option value="bl">Bottom-left</option>
|
||
<option value="br">Bottom-right</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Size slider -->
|
||
<div class="mt-3">
|
||
<label for="h3d-section-hud-size" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Size: <span id="h3d-section-hud-size-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-section-hud-size" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-section-hud-size-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetSectionHudSize && window.h3dBgSetSectionHudSize(this.value)"
|
||
onchange="window.h3dBgSetSectionHudSize && window.h3dBgSetSectionHudSize(this.value)"
|
||
class="w-full accent-accent">
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1"><span>Small</span><span>Large</span></div>
|
||
</div>
|
||
|
||
<!-- Advanced: legacy on-highway labels -->
|
||
<details class="mt-4 rounded-lg border border-gray-800 bg-dark-700/40">
|
||
<summary class="cursor-pointer select-none px-3 py-2 text-xs font-medium text-gray-400 hover:text-gray-200">
|
||
Advanced section settings
|
||
</summary>
|
||
<div class="px-3 pb-3 pt-1">
|
||
<div class="mt-2">
|
||
<label class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-section-labels-on-highway"
|
||
onchange="window.h3dBgSetSectionLabelsOnHighway && window.h3dBgSetSectionLabelsOnHighway(this.checked)"
|
||
class="accent-accent">
|
||
Show section labels on the highway
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 mt-1 ml-6">
|
||
Legacy in-scene cue: cyan section names fly down the
|
||
fretboard alongside the notes. Off by default — the
|
||
"Up Next" card above is the primary surface. Enable
|
||
if you want both.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-tone-hud-heading" class="mt-6">
|
||
<h3 id="h3d-tone-hud-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Tone Changes</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
Overlay card showing the active tone and the next upcoming tone with a
|
||
countdown. Only appears on songs that carry tone-change data. Displayed
|
||
in amber to distinguish it from the cyan section card.
|
||
</p>
|
||
|
||
<!-- Show card -->
|
||
<div class="flex items-center gap-3 mt-3">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-tone-hud-visible"
|
||
onchange="window.h3dBgSetToneHudVisible && window.h3dBgSetToneHudVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show tone change card
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Position -->
|
||
<div class="mt-3">
|
||
<label for="h3d-tone-hud-position" class="text-xs font-medium text-gray-400 mb-1 block">Position</label>
|
||
<select id="h3d-tone-hud-position"
|
||
onchange="window.h3dBgSetToneHudPosition && window.h3dBgSetToneHudPosition(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<option value="tl" selected>Top-left</option>
|
||
<option value="tr">Top-right</option>
|
||
<option value="bl">Bottom-left</option>
|
||
<option value="br">Bottom-right</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Size slider -->
|
||
<div class="mt-3">
|
||
<label for="h3d-tone-hud-size" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Size: <span id="h3d-tone-hud-size-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-tone-hud-size" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-tone-hud-size-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetToneHudSize && window.h3dBgSetToneHudSize(this.value)"
|
||
onchange="window.h3dBgSetToneHudSize && window.h3dBgSetToneHudSize(this.value)"
|
||
class="w-full accent-accent">
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1"><span>Small</span><span>Large</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div role="group" aria-labelledby="h3d-chord-diag-heading" class="mt-6">
|
||
<h3 id="h3d-chord-diag-heading" class="text-sm font-medium text-gray-400 mb-2">3D Highway — Chord Diagram</h3>
|
||
<p class="text-xs text-gray-500 mb-3">
|
||
Overlay shown when a named chord is hit. Fades out after impact.
|
||
</p>
|
||
|
||
<!-- Visibility toggle -->
|
||
<div class="flex items-center gap-3 mt-1 mb-4">
|
||
<label class="flex items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" id="h3d-chord-diagram-visible" checked
|
||
onchange="window.h3dBgSetChordDiagramVisible && window.h3dBgSetChordDiagramVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show chord diagram
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Size slider -->
|
||
<div class="mt-3">
|
||
<label for="h3d-chord-diagram-size" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Size: <span id="h3d-chord-diagram-size-label">0.50</span>
|
||
</label>
|
||
<input type="range" id="h3d-chord-diagram-size" min="0" max="1" step="0.05" value="0.5"
|
||
oninput="document.getElementById('h3d-chord-diagram-size-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetChordDiagramSize && window.h3dBgSetChordDiagramSize(this.value)"
|
||
onchange="window.h3dBgSetChordDiagramSize && window.h3dBgSetChordDiagramSize(this.value)"
|
||
class="w-full accent-accent">
|
||
<div class="flex justify-between text-[10px] text-gray-500 mt-1"><span>Small</span><span>Large</span></div>
|
||
</div>
|
||
|
||
<!-- Position dropdown -->
|
||
<div class="mt-3">
|
||
<label for="h3d-chord-diagram-position" class="text-xs font-medium text-gray-400 mb-1 block">Position</label>
|
||
<select id="h3d-chord-diagram-position"
|
||
onchange="window.h3dBgSetChordDiagramPosition && window.h3dBgSetChordDiagramPosition(this.value)"
|
||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<option value="tl" selected>Top-left</option>
|
||
<option value="tr">Top-right</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Fret-column reference markers cadence -->
|
||
<div class="mt-6 pt-4 border-t border-gray-800">
|
||
<label for="h3d-fret-column-marker-cadence" class="text-xs font-medium text-gray-400 mb-1 block">
|
||
Fret-column marker cadence (measures)
|
||
</label>
|
||
<input type="number" id="h3d-fret-column-marker-cadence" min="0" max="16" step="1" value="1"
|
||
onchange="window.h3dBgSetFretColumnMarkerCadence && window.h3dBgSetFretColumnMarkerCadence(this.value)"
|
||
class="w-24 bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||
<p class="text-[10px] text-gray-500 mt-1">
|
||
How often a row of fret-number reference markers slides down
|
||
the highway floor. With a chart <anchor>, the row uses the
|
||
inlay cadence (3,5,7,9,12,15,…) around the anchor fret—about
|
||
two marker steps back and three forward (e.g. anchor 7 shows
|
||
3,5,7,9,12,15). Without anchors, every cadence fret is eligible.
|
||
Light grey when an upcoming note uses that fret column, dark
|
||
grey otherwise. Set to 0 to disable.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Board "note preview" ghost — the translucent copy of the next
|
||
note shown on the fretboard surface just before it arrives.
|
||
Storage `projectionVisible` (true = shown), natural polarity. -->
|
||
<div class="mt-4">
|
||
<label class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-projection-visible"
|
||
onchange="window.h3dBgSetProjectionVisible && window.h3dBgSetProjectionVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show note preview on the fretboard
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 mt-1 ml-6">
|
||
A faint ghost of the upcoming note that appears on the
|
||
fretboard surface a fraction of a second before the real note
|
||
reaches the strike line. Turn off if it clutters the view.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Static inlay fret-number labels on the fretboard.
|
||
UI is inverted-polarity for clarity: checking the box hides
|
||
the labels. Storage stays `inlayLabelsVisible` (true=visible)
|
||
so the renderer reads the natural-direction flag. -->
|
||
<div class="mt-4">
|
||
<label class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-inlay-labels-visible"
|
||
onchange="window.h3dBgSetInlayLabelsVisible && window.h3dBgSetInlayLabelsVisible(!this.checked)"
|
||
class="accent-accent">
|
||
Hide static fret-inlay number labels
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 mt-1 ml-6">
|
||
Teal numbers (3, 5, 7, 9, 12, 15, 17, 19, 22, 24) painted
|
||
between the strings on the fretboard at the standard inlay
|
||
positions. When visible, they stay fixed (don't scroll).
|
||
Independent of the rolling fret-column markers above.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Fret boundary divider lines -->
|
||
<div class="mt-4">
|
||
<label class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||
<input type="checkbox" id="h3d-fret-dividers-visible" checked
|
||
onchange="window.h3dBgSetFretDividersVisible && window.h3dBgSetFretDividersVisible(this.checked)"
|
||
class="accent-accent">
|
||
Show fret boundary divider lines
|
||
</label>
|
||
<p class="text-[10px] text-gray-500 mt-1 ml-6">
|
||
Thin vertical lines at every fret across the full highway width.
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Hydrate from localStorage on load. Mirror runtime defaults AND
|
||
coercion rules from screen.js's BG_DEFAULTS / _bgCoerce so first-
|
||
run users see what the renderer actually drew, and any garbage
|
||
in localStorage (out-of-range intensity, unknown style id) is
|
||
clamped to a valid value instead of putting the controls in a
|
||
no-selection / NaN state. -->
|
||
<script>
|
||
(function () {
|
||
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
|
||
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video']);
|
||
// Scene color themes — the single id+label source for BOTH the
|
||
// Background and Highway dropdowns AND the validator below. The
|
||
// <option>s are generated from this list (see hydration), so the two
|
||
// dropdowns can't drift from each other or from VALID_BG_THEMES.
|
||
// Mirror these ids with screen.js BG_THEMES (the color table).
|
||
const SCENE_THEMES = [
|
||
{ id: 'default', label: 'Default (blue-black)' },
|
||
{ id: 'midnight', label: 'Midnight (deep blue)' },
|
||
{ id: 'charcoal', label: 'Charcoal (neutral gray)' },
|
||
{ id: 'deeppurple', label: 'Deep purple' },
|
||
{ id: 'forest', label: 'Forest (dark green)' },
|
||
{ id: 'warmslate', label: 'Warm Slate (espresso)' },
|
||
{ id: 'deepfocus', label: 'Deep Focus (near-black)' },
|
||
{ id: 'deepsea', label: 'Deep Sea (dark teal)' },
|
||
{ id: 'cathode', label: 'Cathode (amber CRT)' },
|
||
{ id: 'cathodegreen', label: 'Cathode Green (green CRT)' },
|
||
{ id: 'hearth', label: 'Hearth (warm red)' },
|
||
];
|
||
const VALID_BG_THEMES = new Set(SCENE_THEMES.map((t) => t.id));
|
||
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
|
||
// Chord diagram is top-only (bl/br removed).
|
||
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
|
||
// Section HUD still supports all four corners independently.
|
||
const VALID_SECTION_HUD_POSITIONS = new Set(['tl', 'tr', 'bl', 'br']);
|
||
const VALID_FRET_GHOST_SCOPES = new Set(['chords', 'all']);
|
||
// Custom images are persisted as base64 data URLs in
|
||
// localStorage. base64 expands the byte count by ~4/3, and
|
||
// localStorage values are stored as UTF-16 (two bytes per
|
||
// character), so the on-disk footprint is ~2.67× the raw
|
||
// file size. localStorage quotas are typically 5 MB per
|
||
// origin and feedBack already uses some of that for other
|
||
// settings, so a 1.5 MB raw limit (≈4 MB on disk) leaves
|
||
// safe headroom; the read-back verification below catches
|
||
// remaining edge cases where the write still gets refused.
|
||
const MAX_IMAGE_BYTES = Math.floor(1.5 * 1024 * 1024);
|
||
// Hard cap on the encoded data URL length as a second line
|
||
// of defense. base64 expands raw by ~4/3, so a max-size
|
||
// (1.5 MB) upload encodes to ~2.0 M chars + a short
|
||
// `data:image/...;base64,` prefix. 2.5 M chars gives ~25%
|
||
// headroom to catch outliers without ever firing on
|
||
// legitimate uploads that already pass MAX_IMAGE_BYTES.
|
||
// Mirrored in screen.js as a renderer-side guard against
|
||
// legacy / manually-edited localStorage payloads.
|
||
const MAX_DATA_URL_CHARS = Math.floor(2.5 * 1024 * 1024);
|
||
// Strict whitelist of allowed file MIME types, matching the
|
||
// file picker's `accept` attribute and screen.js's
|
||
// upfront `^data:image/(jpeg|png|webp);` validator.
|
||
const ALLOWED_IMAGE_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||
// Defense against decompression bombs — a small ≤1.5 MB
|
||
// file can decode to enormous pixel dimensions (e.g.
|
||
// 50000×50000), which would allocate gigabytes of GPU
|
||
// memory once Three.js uploads it as a texture and almost
|
||
// certainly crash the tab. 4096 per axis is plenty for a
|
||
// backdrop (more pixels are wasted at fog-band distance);
|
||
// 16 MP total catches non-square shapes that pass the
|
||
// per-axis cap but still aggregate to a huge area.
|
||
const MAX_IMAGE_DIM = 4096;
|
||
const MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
|
||
|
||
// Video uploads go to disk via routes.py — no localStorage
|
||
// expansion to worry about, so the cap is just "what's
|
||
// reasonable for a backdrop loop" (a 50 MB MP4 covers ~30
|
||
// seconds at 1080p, plenty for a stage backdrop).
|
||
const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
|
||
const ALLOWED_VIDEO_MIMES = new Set(['video/mp4', 'video/webm']);
|
||
// Server-side filenames are deterministic single-slot
|
||
// (current.<ext>); this pattern matches what routes.py
|
||
// produces and what screen.js's video style validates.
|
||
// Case-sensitive: routes.py only ever produces lowercase
|
||
// (it lowercases the upload extension) and only serves
|
||
// lowercase. Callers are responsible for lowercasing the
|
||
// candidate name before testing.
|
||
const VIDEO_FILENAME_RE = /^current\.(mp4|webm)$/;
|
||
const VIDEO_UPLOAD_URL = '/api/plugins/highway_3d/files';
|
||
|
||
function coerceStyle(v) { return VALID_STYLES.has(v) ? v : DEFAULTS.style; }
|
||
function coerceBgTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.bgTheme; }
|
||
// Highway axis shares the same valid id-set as the background axis.
|
||
function coerceHwTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.hwTheme; }
|
||
function coerceCameraMode(v) {
|
||
if (v === 'classic') v = 'steady';
|
||
return VALID_CAMERA_MODES.has(v) ? v : DEFAULTS.cameraMode;
|
||
}
|
||
function coerceIntensity(v) {
|
||
const n = parseFloat(v);
|
||
if (!Number.isFinite(n)) return DEFAULTS.intensity;
|
||
return Math.max(0, Math.min(1, n));
|
||
}
|
||
// Generic 0..1 clamp + parseFloat with an explicit fallback.
|
||
// Each unit-interval slider passes its own DEFAULTS[key] so a
|
||
// future bound change on one setting doesn't drag the others
|
||
// along, and so this stays in sync with screen.js's _bgCoerce
|
||
// which also keys the fallback to BG_DEFAULTS[key].
|
||
function coerceUnitInterval(v, fallback) {
|
||
const n = parseFloat(v);
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.max(0, Math.min(1, n));
|
||
}
|
||
function coerceBool(v, fallback) {
|
||
if (v === null || v === undefined) return fallback;
|
||
if (v === 'true' || v === '1') return true;
|
||
if (v === 'false' || v === '0') return false;
|
||
return fallback;
|
||
}
|
||
function coerceFretGhostScope(v, fallback) {
|
||
if (v === null || v === undefined) return fallback;
|
||
return VALID_FRET_GHOST_SCOPES.has(String(v)) ? String(v) : fallback;
|
||
}
|
||
function coerceCadence(v) {
|
||
const n = parseInt(v, 10);
|
||
if (!Number.isFinite(n)) return DEFAULTS.fretColumnMarkerCadence;
|
||
return Math.max(0, Math.min(16, n));
|
||
}
|
||
function coerceHex6(v, fallback) {
|
||
if (typeof v !== 'string') return fallback;
|
||
const t = v.trim();
|
||
if (/^#[0-9a-fA-F]{6}$/.test(t)) return t.toLowerCase();
|
||
return fallback;
|
||
}
|
||
|
||
let storedStyle = null, storedBgTheme = null, storedHwTheme = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
|
||
let storedCameraSmoothing = null;
|
||
let storedCameraMode = null;
|
||
let storedCustomImageDataUrl = null, storedCustomImageName = null;
|
||
let storedCustomVideoName = null;
|
||
let storedZoomSmoothing = null;
|
||
let storedTiltSmoothing = null;
|
||
let storedCameraLockLow = null;
|
||
let storedCameraLockZoom = null;
|
||
let storedTextSize = null;
|
||
let storedVibrancy = null;
|
||
let storedGlow = null;
|
||
let storedChordDiagramVisible = null;
|
||
let storedChordDiagramSize = null;
|
||
let storedChordDiagramPosition = null;
|
||
let storedFretColumnMarkerCadence = null;
|
||
let storedInlayLabelsVisible = null;
|
||
let storedSectionLabelsOnHighway = null;
|
||
let storedSectionHudVisible = null;
|
||
let storedSectionHudPosition = null;
|
||
let storedSectionHudSize = null;
|
||
let storedToneHudVisible = null;
|
||
let storedToneHudPosition = null;
|
||
let storedToneHudSize = null;
|
||
let storedNutHeadstockVisible = null;
|
||
let storedTuningLabelsVisible = null;
|
||
let storedNutColor = null;
|
||
let storedHeadstockColor = null;
|
||
let storedFpsVisible = null;
|
||
let storedProjectionVisible = null;
|
||
let storedFretDividersVisible = null;
|
||
let storedSlideArrowApproachVisible = null;
|
||
let storedSlideArrowNeckVisible = null;
|
||
let storedSlideArrowChainPreviewVisible = null;
|
||
try {
|
||
storedStyle = localStorage.getItem('h3d_bg_style');
|
||
storedBgTheme = localStorage.getItem('h3d_bg_bgTheme');
|
||
storedHwTheme = localStorage.getItem('h3d_bg_hwTheme');
|
||
storedI = localStorage.getItem('h3d_bg_intensity');
|
||
storedR = localStorage.getItem('h3d_bg_reactive');
|
||
storedFretOnNote = localStorage.getItem('h3d_bg_showFretOnNote');
|
||
storedFretNumberGhostScope = localStorage.getItem('h3d_bg_fretNumberGhostScope');
|
||
storedCameraSmoothing = localStorage.getItem('h3d_bg_cameraSmoothing');
|
||
storedCameraMode = localStorage.getItem('h3d_bg_cameraMode');
|
||
storedZoomSmoothing = localStorage.getItem('h3d_bg_zoomSmoothing');
|
||
storedTiltSmoothing = localStorage.getItem('h3d_bg_tiltSmoothing');
|
||
storedCameraLockLow = localStorage.getItem('h3d_bg_cameraLockLow');
|
||
storedCameraLockZoom = localStorage.getItem('h3d_bg_cameraLockZoom');
|
||
storedTextSize = localStorage.getItem('h3d_bg_textSize');
|
||
storedVibrancy = localStorage.getItem('h3d_bg_vibrancy');
|
||
storedGlow = localStorage.getItem('h3d_bg_glow');
|
||
storedChordDiagramVisible = localStorage.getItem('h3d_bg_chordDiagramVisible');
|
||
storedChordDiagramSize = localStorage.getItem('h3d_bg_chordDiagramSize');
|
||
storedChordDiagramPosition = localStorage.getItem('h3d_bg_chordDiagramPosition');
|
||
storedFretColumnMarkerCadence = localStorage.getItem('h3d_bg_fretColumnMarkerCadence');
|
||
storedInlayLabelsVisible = localStorage.getItem('h3d_bg_inlayLabelsVisible');
|
||
storedSectionLabelsOnHighway = localStorage.getItem('h3d_bg_sectionLabelsOnHighway');
|
||
storedSectionHudVisible = localStorage.getItem('h3d_bg_sectionHudVisible');
|
||
storedSectionHudPosition = localStorage.getItem('h3d_bg_sectionHudPosition');
|
||
storedSectionHudSize = localStorage.getItem('h3d_bg_sectionHudSize');
|
||
storedToneHudVisible = localStorage.getItem('h3d_bg_toneHudVisible');
|
||
storedToneHudPosition = localStorage.getItem('h3d_bg_toneHudPosition');
|
||
storedToneHudSize = localStorage.getItem('h3d_bg_toneHudSize');
|
||
storedNutHeadstockVisible = localStorage.getItem('h3d_bg_nutHeadstockVisible');
|
||
storedTuningLabelsVisible = localStorage.getItem('h3d_bg_tuningLabelsVisible');
|
||
storedNutColor = localStorage.getItem('h3d_bg_nutColor');
|
||
storedHeadstockColor = localStorage.getItem('h3d_bg_headstockColor');
|
||
storedFpsVisible = localStorage.getItem('h3d_bg_fpsVisible');
|
||
storedProjectionVisible = localStorage.getItem('h3d_bg_projectionVisible');
|
||
storedFretDividersVisible = localStorage.getItem('h3d_bg_fretDividersVisible');
|
||
storedSlideArrowApproachVisible = localStorage.getItem('h3d_bg_slideArrowApproachVisible');
|
||
storedSlideArrowNeckVisible = localStorage.getItem('h3d_bg_slideArrowNeckVisible');
|
||
storedSlideArrowChainPreviewVisible = localStorage.getItem('h3d_bg_slideArrowChainPreviewVisible');
|
||
storedCustomImageDataUrl = localStorage.getItem('h3d_bg_customImageDataUrl');
|
||
storedCustomImageName = localStorage.getItem('h3d_bg_customImageName');
|
||
storedCustomVideoName = localStorage.getItem('h3d_bg_customVideoName');
|
||
} catch (_) { /* storage blocked */ }
|
||
|
||
const style = coerceStyle(storedStyle);
|
||
const bgTheme = coerceBgTheme(storedBgTheme);
|
||
// Highway axis. BACKWARD-COMPAT: when hwTheme was never written
|
||
// (pre-split installs, or the panel opened before the highway has
|
||
// mounted and run its one-time backfill), show the background pick —
|
||
// exactly the value screen.js will persist on first load — so the
|
||
// dropdown matches what's rendered. Once hwTheme is stored (which the
|
||
// renderer's backfill or any explicit pick does) it reads
|
||
// independently and the two axes no longer track each other.
|
||
const hwTheme = (storedHwTheme == null) ? bgTheme : coerceHwTheme(storedHwTheme);
|
||
const intensity = coerceIntensity(storedI);
|
||
const reactive = coerceBool(storedR, DEFAULTS.reactive);
|
||
const showFretOnNote = coerceBool(storedFretOnNote, DEFAULTS.showFretOnNote);
|
||
const fretNumberGhostScope = coerceFretGhostScope(storedFretNumberGhostScope, DEFAULTS.fretNumberGhostScope);
|
||
const cameraSmoothing = coerceUnitInterval(storedCameraSmoothing, DEFAULTS.cameraSmoothing);
|
||
// zoom + tilt smoothing mirror cameraSmoothing on first read so
|
||
// existing users carry their calmness over to the new axes.
|
||
// After the user moves either slider once, the stored value
|
||
// takes over and the two become independent.
|
||
const zoomSmoothing = (storedZoomSmoothing == null)
|
||
? cameraSmoothing
|
||
: coerceUnitInterval(storedZoomSmoothing, DEFAULTS.zoomSmoothing);
|
||
const tiltSmoothing = (storedTiltSmoothing == null)
|
||
? cameraSmoothing
|
||
: coerceUnitInterval(storedTiltSmoothing, DEFAULTS.tiltSmoothing);
|
||
const cameraLockLow = coerceBool(storedCameraLockLow, DEFAULTS.cameraLockLow);
|
||
const cameraLockZoom = (storedCameraLockZoom == null)
|
||
? DEFAULTS.cameraLockZoom
|
||
: coerceUnitInterval(storedCameraLockZoom, DEFAULTS.cameraLockZoom);
|
||
const cameraMode = coerceCameraMode(storedCameraMode);
|
||
const textSize = (storedTextSize == null)
|
||
? DEFAULTS.textSize
|
||
: coerceUnitInterval(storedTextSize, DEFAULTS.textSize);
|
||
const vibrancy = (storedVibrancy == null)
|
||
? DEFAULTS.vibrancy
|
||
: coerceUnitInterval(storedVibrancy, DEFAULTS.vibrancy);
|
||
const glow = (storedGlow == null)
|
||
? DEFAULTS.glow
|
||
: coerceUnitInterval(storedGlow, DEFAULTS.glow);
|
||
const chordDiagramVisible = coerceBool(storedChordDiagramVisible, DEFAULTS.chordDiagramVisible);
|
||
const chordDiagramSize = (storedChordDiagramSize == null)
|
||
? DEFAULTS.chordDiagramSize
|
||
: coerceUnitInterval(storedChordDiagramSize, DEFAULTS.chordDiagramSize);
|
||
const chordDiagramPosition = (storedChordDiagramPosition !== null && VALID_CHORD_DIAG_POSITIONS.has(storedChordDiagramPosition))
|
||
? storedChordDiagramPosition
|
||
: DEFAULTS.chordDiagramPosition;
|
||
const fretColumnMarkerCadence = (storedFretColumnMarkerCadence == null)
|
||
? DEFAULTS.fretColumnMarkerCadence
|
||
: coerceCadence(storedFretColumnMarkerCadence);
|
||
const inlayLabelsVisible = coerceBool(storedInlayLabelsVisible, DEFAULTS.inlayLabelsVisible);
|
||
const sectionLabelsOnHighway = coerceBool(storedSectionLabelsOnHighway, DEFAULTS.sectionLabelsOnHighway);
|
||
const sectionHudVisible = coerceBool(storedSectionHudVisible, DEFAULTS.sectionHudVisible);
|
||
const sectionHudPosition = (storedSectionHudPosition !== null && VALID_SECTION_HUD_POSITIONS.has(storedSectionHudPosition))
|
||
? storedSectionHudPosition
|
||
: DEFAULTS.sectionHudPosition;
|
||
const sectionHudSize = (storedSectionHudSize == null)
|
||
? DEFAULTS.sectionHudSize
|
||
: coerceUnitInterval(storedSectionHudSize, DEFAULTS.sectionHudSize);
|
||
const toneHudVisible = coerceBool(storedToneHudVisible, DEFAULTS.toneHudVisible);
|
||
const toneHudPosition = (storedToneHudPosition !== null && VALID_SECTION_HUD_POSITIONS.has(storedToneHudPosition))
|
||
? storedToneHudPosition
|
||
: DEFAULTS.toneHudPosition;
|
||
const toneHudSize = (storedToneHudSize == null)
|
||
? DEFAULTS.toneHudSize
|
||
: coerceUnitInterval(storedToneHudSize, DEFAULTS.toneHudSize);
|
||
const nutHeadstockVisible = coerceBool(storedNutHeadstockVisible, DEFAULTS.nutHeadstockVisible);
|
||
const tuningLabelsVisible = coerceBool(storedTuningLabelsVisible, DEFAULTS.tuningLabelsVisible);
|
||
const nutColor = coerceHex6(storedNutColor, DEFAULTS.nutColor);
|
||
const headstockColor = coerceHex6(storedHeadstockColor, DEFAULTS.headstockColor);
|
||
const fpsVisible = coerceBool(storedFpsVisible, DEFAULTS.fpsVisible);
|
||
const projectionVisible = coerceBool(storedProjectionVisible, DEFAULTS.projectionVisible);
|
||
const fretDividersVisible = coerceBool(storedFretDividersVisible, DEFAULTS.fretDividersVisible);
|
||
const slideArrowApproachVisible = coerceBool(storedSlideArrowApproachVisible, DEFAULTS.slideArrowApproachVisible);
|
||
const slideArrowNeckVisible = coerceBool(storedSlideArrowNeckVisible, DEFAULTS.slideArrowNeckVisible);
|
||
const slideArrowChainPreviewVisible = coerceBool(storedSlideArrowChainPreviewVisible, DEFAULTS.slideArrowChainPreviewVisible);
|
||
const customImageDataUrl = (typeof storedCustomImageDataUrl === 'string') ? storedCustomImageDataUrl : DEFAULTS.customImageDataUrl;
|
||
const customImageName = (typeof storedCustomImageName === 'string') ? storedCustomImageName : DEFAULTS.customImageName;
|
||
const customVideoName = (typeof storedCustomVideoName === 'string') ? storedCustomVideoName : DEFAULTS.customVideoName;
|
||
|
||
const sel = document.getElementById('h3d-bg-style');
|
||
const bgThemeSel = document.getElementById('h3d-bg-theme');
|
||
const hwThemeSel = document.getElementById('h3d-hw-theme');
|
||
// Populate both scene-theme dropdowns from the single SCENE_THEMES
|
||
// source so they can't drift. (Values are selected further below.)
|
||
for (const selEl of [bgThemeSel, hwThemeSel]) {
|
||
if (!selEl) continue;
|
||
selEl.innerHTML = '';
|
||
for (const t of SCENE_THEMES) {
|
||
const opt = document.createElement('option');
|
||
opt.value = t.id;
|
||
opt.textContent = t.label;
|
||
selEl.appendChild(opt);
|
||
}
|
||
}
|
||
const sli = document.getElementById('h3d-bg-intensity');
|
||
const lbl = document.getElementById('h3d-bg-intensity-label');
|
||
const rea = document.getElementById('h3d-bg-reactive');
|
||
const fon = document.getElementById('h3d-show-fret-on-note');
|
||
const csli = document.getElementById('h3d-camera-smoothing');
|
||
const clbl = document.getElementById('h3d-camera-smoothing-label');
|
||
const zsli = document.getElementById('h3d-zoom-smoothing');
|
||
const zlbl = document.getElementById('h3d-zoom-smoothing-label');
|
||
const tsli = document.getElementById('h3d-tilt-smoothing');
|
||
const tlbl = document.getElementById('h3d-tilt-smoothing-label');
|
||
const cll = document.getElementById('h3d-camera-lock-low');
|
||
const cmod = document.getElementById('h3d-camera-mode');
|
||
const lzli = document.getElementById('h3d-camera-lock-zoom');
|
||
const lzlbl = document.getElementById('h3d-camera-lock-zoom-label');
|
||
const tsi = document.getElementById('h3d-text-size');
|
||
const tslbl = document.getElementById('h3d-text-size-label');
|
||
const vsli = document.getElementById('h3d-vibrancy');
|
||
const vlbl = document.getElementById('h3d-vibrancy-label');
|
||
const gsli = document.getElementById('h3d-glow');
|
||
const glbl = document.getElementById('h3d-glow-label');
|
||
const cdvis = document.getElementById('h3d-chord-diagram-visible');
|
||
const cdsli = document.getElementById('h3d-chord-diagram-size');
|
||
const cdslbl= document.getElementById('h3d-chord-diagram-size-label');
|
||
const cdpos = document.getElementById('h3d-chord-diagram-position');
|
||
const fcmc = document.getElementById('h3d-fret-column-marker-cadence');
|
||
const ilv = document.getElementById('h3d-inlay-labels-visible');
|
||
const slhi = document.getElementById('h3d-section-labels-on-highway');
|
||
const shvi = document.getElementById('h3d-section-hud-visible');
|
||
const shpos = document.getElementById('h3d-section-hud-position');
|
||
const shsi = document.getElementById('h3d-section-hud-size');
|
||
const shslbl= document.getElementById('h3d-section-hud-size-label');
|
||
const thvi = document.getElementById('h3d-tone-hud-visible');
|
||
const thpos = document.getElementById('h3d-tone-hud-position');
|
||
const thsi = document.getElementById('h3d-tone-hud-size');
|
||
const thslbl= document.getElementById('h3d-tone-hud-size-label');
|
||
const nhv = document.getElementById('h3d-nut-headstock-visible');
|
||
const tlv = document.getElementById('h3d-tuning-labels-visible');
|
||
const nco = document.getElementById('h3d-nut-color');
|
||
const hco = document.getElementById('h3d-headstock-color');
|
||
const fpv = document.getElementById('h3d-fps-visible');
|
||
const pgh = document.getElementById('h3d-projection-visible');
|
||
const saav = document.getElementById('h3d-slide-arrow-approach-visible');
|
||
const sanv = document.getElementById('h3d-slide-arrow-neck-visible');
|
||
const sacpv = document.getElementById('h3d-slide-arrow-chain-preview-visible');
|
||
const fileInput = document.getElementById('h3d-bg-custom-image-file');
|
||
const clearBtn = document.getElementById('h3d-bg-custom-image-clear');
|
||
const nameP = document.getElementById('h3d-bg-custom-image-name');
|
||
const errorP = document.getElementById('h3d-bg-custom-image-error');
|
||
const videoFileInput = document.getElementById('h3d-bg-custom-video-file');
|
||
const videoClearBtn = document.getElementById('h3d-bg-custom-video-clear');
|
||
const videoNameP = document.getElementById('h3d-bg-custom-video-name');
|
||
const videoErrorP = document.getElementById('h3d-bg-custom-video-error');
|
||
if (sli) sli.value = String(intensity);
|
||
if (lbl) lbl.textContent = intensity.toFixed(2);
|
||
if (rea) rea.checked = reactive;
|
||
if (fon) fon.checked = showFretOnNote;
|
||
const fgsRock = document.getElementById('h3d-fret-ghost-scope-chords');
|
||
const fgsAll = document.getElementById('h3d-fret-ghost-scope-all');
|
||
const fgsWrap = document.getElementById('h3d-fret-ghost-scope-wrap');
|
||
if (fretNumberGhostScope === 'all') { if (fgsAll) fgsAll.checked = true; }
|
||
else { if (fgsRock) fgsRock.checked = true; }
|
||
if (fgsWrap) fgsWrap.classList.toggle('hidden', !showFretOnNote);
|
||
|
||
function syncH3dFretGhostScopeUi() {
|
||
const c = document.getElementById('h3d-show-fret-on-note');
|
||
const w = document.getElementById('h3d-fret-ghost-scope-wrap');
|
||
if (w && c) w.classList.toggle('hidden', !c.checked);
|
||
}
|
||
if (fon) {
|
||
fon.addEventListener('change', function () {
|
||
if (window.h3dBgSetShowFretOnNote) window.h3dBgSetShowFretOnNote(this.checked);
|
||
syncH3dFretGhostScopeUi();
|
||
});
|
||
}
|
||
document.querySelectorAll('input[name="h3d-fret-ghost-scope"]').forEach(function (r) {
|
||
r.addEventListener('change', function () {
|
||
if (this.checked && window.h3dBgSetFretNumberGhostScope)
|
||
window.h3dBgSetFretNumberGhostScope(this.value);
|
||
});
|
||
});
|
||
if (csli) csli.value = String(cameraSmoothing);
|
||
if (clbl) clbl.textContent = cameraSmoothing.toFixed(2);
|
||
if (zsli) zsli.value = String(zoomSmoothing);
|
||
if (zlbl) zlbl.textContent = zoomSmoothing.toFixed(2);
|
||
if (tsli) tsli.value = String(tiltSmoothing);
|
||
if (tlbl) tlbl.textContent = tiltSmoothing.toFixed(2);
|
||
if (cll) cll.checked = cameraLockLow;
|
||
if (cmod) cmod.value = cameraMode;
|
||
if (nhv) nhv.checked = nutHeadstockVisible;
|
||
if (tlv) tlv.checked = tuningLabelsVisible;
|
||
if (nco) nco.value = nutColor;
|
||
if (hco) hco.value = headstockColor;
|
||
if (lzli) lzli.value = String(cameraLockZoom);
|
||
if (lzlbl) lzlbl.textContent = cameraLockZoom.toFixed(2);
|
||
if (tsi) tsi.value = String(textSize);
|
||
if (tslbl) tslbl.textContent = textSize.toFixed(2);
|
||
if (vsli) vsli.value = String(vibrancy);
|
||
if (vlbl) vlbl.textContent = vibrancy.toFixed(2);
|
||
if (gsli) gsli.value = String(glow);
|
||
if (glbl) glbl.textContent = glow.toFixed(2);
|
||
if (cdvis) cdvis.checked = chordDiagramVisible;
|
||
if (cdsli) cdsli.value = String(chordDiagramSize);
|
||
if (cdslbl) cdslbl.textContent = chordDiagramSize.toFixed(2);
|
||
if (cdpos) cdpos.value = chordDiagramPosition;
|
||
if (fcmc) fcmc.value = String(fretColumnMarkerCadence);
|
||
if (ilv) ilv.checked = !inlayLabelsVisible;
|
||
if (fpv) fpv.checked = fpsVisible;
|
||
if (pgh) pgh.checked = projectionVisible;
|
||
const fdv = document.getElementById('h3d-fret-dividers-visible');
|
||
if (fdv) fdv.checked = fretDividersVisible;
|
||
if (saav) saav.checked = slideArrowApproachVisible;
|
||
if (sanv) sanv.checked = slideArrowNeckVisible;
|
||
if (sacpv) sacpv.checked = slideArrowChainPreviewVisible;
|
||
if (slhi) slhi.checked = sectionLabelsOnHighway;
|
||
if (shvi) shvi.checked = sectionHudVisible;
|
||
if (shpos) shpos.value = sectionHudPosition;
|
||
if (shsi) shsi.value = String(sectionHudSize);
|
||
if (shslbl) shslbl.textContent = sectionHudSize.toFixed(2);
|
||
if (thvi) thvi.checked = toneHudVisible;
|
||
if (thpos) thpos.value = toneHudPosition;
|
||
if (thsi) thsi.value = String(toneHudSize);
|
||
if (thslbl) thslbl.textContent = toneHudSize.toFixed(2);
|
||
|
||
// Hit-feedback "juice" controls — hydrate from saved state so the
|
||
// panel reflects persistence on reopen (the renderer already reads
|
||
// these via _bgReadSetting; without this the controls always showed
|
||
// their default markup, misrepresenting a saved non-default). Reads
|
||
// h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
|
||
// hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' rules.
|
||
try {
|
||
const _bgBool = (k, def) => {
|
||
const v = localStorage.getItem('h3d_bg_' + k);
|
||
return v == null ? def : !(v === 'false' || v === '0');
|
||
};
|
||
const _setChk = (id, on) => { const el = document.getElementById(id); if (el) el.checked = on; };
|
||
_setChk('h3d-sparks', _bgBool('sparks', true));
|
||
_setChk('h3d-cinematic', _bgBool('cinematic', true));
|
||
_setChk('h3d-streakfx', _bgBool('streakFx', true));
|
||
_setChk('h3d-verdictmarks', _bgBool('verdictMarks', true));
|
||
_setChk('h3d-bloom', _bgBool('bloom', true));
|
||
_setChk('h3d-timingfx', _bgBool('timingFx', true));
|
||
const _hf = document.getElementById('h3d-hitfx');
|
||
if (_hf) {
|
||
let v = parseFloat(localStorage.getItem('h3d_bg_hitFx'));
|
||
if (!isFinite(v)) v = 0.70;
|
||
v = Math.max(0, Math.min(1, v));
|
||
_hf.value = String(v);
|
||
const _hfl = document.getElementById('h3d-hitfx-label');
|
||
if (_hfl) _hfl.textContent = v.toFixed(2);
|
||
}
|
||
} catch (_) { /* storage blocked — controls keep their default markup */ }
|
||
|
||
// (3D Highway palette picker removed — string colors are now set
|
||
// via the core "Highway String Colors" UI above, which drives both
|
||
// the 2D and 3D highways. The bg-settings 'palette' key still exists
|
||
// internally: 'default' (stock) or 'custom' (driven by the pickers).)
|
||
|
||
function imageOption() {
|
||
if (!sel) return null;
|
||
for (let i = 0; i < sel.options.length; i++) {
|
||
if (sel.options[i].value === 'image') return sel.options[i];
|
||
}
|
||
return null;
|
||
}
|
||
// Reflect "is there an asset?" in the picker. Keeping this
|
||
// tied to data presence (rather than a separate flag) means
|
||
// a clear-then-refresh cycle stays consistent without extra
|
||
// bookkeeping.
|
||
function refreshImageOptionState(hasAsset, displayName) {
|
||
const opt = imageOption();
|
||
if (opt) {
|
||
opt.disabled = !hasAsset;
|
||
opt.textContent = hasAsset
|
||
? ('Custom image' + (displayName ? ' — ' + displayName : ''))
|
||
: 'Custom image (none uploaded)';
|
||
}
|
||
if (nameP) nameP.textContent = hasAsset
|
||
? ('Current: ' + (displayName || 'unnamed'))
|
||
: 'No image uploaded.';
|
||
}
|
||
function showError(msg) {
|
||
if (!errorP) return;
|
||
errorP.textContent = msg;
|
||
errorP.classList.remove('hidden');
|
||
}
|
||
function clearError() {
|
||
if (!errorP) return;
|
||
errorP.textContent = '';
|
||
errorP.classList.add('hidden');
|
||
}
|
||
|
||
function videoOption() {
|
||
if (!sel) return null;
|
||
for (let i = 0; i < sel.options.length; i++) {
|
||
if (sel.options[i].value === 'video') return sel.options[i];
|
||
}
|
||
return null;
|
||
}
|
||
function refreshVideoOptionState(hasVideo, displayName) {
|
||
const opt = videoOption();
|
||
if (opt) {
|
||
opt.disabled = !hasVideo;
|
||
opt.textContent = hasVideo
|
||
? ('Custom video' + (displayName ? ' — ' + displayName : ''))
|
||
: 'Custom video (none uploaded)';
|
||
}
|
||
if (videoNameP) videoNameP.textContent = hasVideo
|
||
? ('Current: ' + (displayName || 'unnamed'))
|
||
: 'No video uploaded.';
|
||
}
|
||
function showVideoError(msg) {
|
||
if (!videoErrorP) return;
|
||
videoErrorP.textContent = msg;
|
||
videoErrorP.classList.remove('hidden');
|
||
}
|
||
function clearVideoError() {
|
||
if (!videoErrorP) return;
|
||
videoErrorP.textContent = '';
|
||
videoErrorP.classList.add('hidden');
|
||
}
|
||
|
||
// hasAsset must use the same validator as screen.js. A bare
|
||
// truthy check would treat whitespace, garbage strings, or
|
||
// unsupported types (e.g. data:image/svg+xml left over from
|
||
// an older version's storage) as "asset present" and let
|
||
// the UI enable + select the `image` option, but the
|
||
// renderer would reject the bytes and mount nothing —
|
||
// leaving the user with a non-functional selection and a
|
||
// blank backdrop they couldn't diagnose.
|
||
const trimmedCustomImageDataUrl = (typeof customImageDataUrl === 'string')
|
||
? customImageDataUrl.trim() : '';
|
||
const hasAsset = /^data:image\/(jpeg|png|webp);/i.test(trimmedCustomImageDataUrl);
|
||
refreshImageOptionState(hasAsset, customImageName);
|
||
|
||
// Same shape for video: validate the persisted filename
|
||
// against the renderer-side pattern so a stale/manually-
|
||
// edited localStorage value (e.g. left over from a future
|
||
// schema change) doesn't enable a non-functional option.
|
||
// Lowercase normalization: routes.py only produces lowercase
|
||
// names, so a manually-edited `current.MP4` would otherwise
|
||
// pass an i-flag check and then 404 against the server.
|
||
const trimmedCustomVideoName = (typeof customVideoName === 'string')
|
||
? customVideoName.trim().toLowerCase() : '';
|
||
const hasVideo = VIDEO_FILENAME_RE.test(trimmedCustomVideoName);
|
||
refreshVideoOptionState(hasVideo, trimmedCustomVideoName);
|
||
|
||
// Apply style AFTER both option disabled-states are settled.
|
||
// If style === 'image'/'video' but no asset is uploaded
|
||
// (corrupt localStorage, manual edit), persist the fallback
|
||
// through the setter so storage / UI / renderer all agree
|
||
// — without the setter call we'd leave the bad style in
|
||
// localStorage while the picker shows particles, and the
|
||
// renderer would mount the inert style (build returns null)
|
||
// for a blank backdrop that doesn't match the visible UI.
|
||
//
|
||
// NOTE: settings.html normally runs BEFORE screen.js loads
|
||
// (app.js injects settings first, then appends the plugin
|
||
// script), so window.h3dBgSetStyle is usually undefined at
|
||
// this point. Write the corrected value directly to
|
||
// localStorage so that when screen.js does load it reads the
|
||
// already-fixed value rather than the stale 'video'/'image'.
|
||
if (sel) {
|
||
if ((style === 'image' && !hasAsset) || (style === 'video' && !hasVideo)) {
|
||
sel.value = DEFAULTS.style;
|
||
try { localStorage.setItem('h3d_bg_style', DEFAULTS.style); } catch (_) { /* storage blocked */ }
|
||
if (window.h3dBgSetStyle) window.h3dBgSetStyle(DEFAULTS.style);
|
||
} else {
|
||
sel.value = style;
|
||
}
|
||
}
|
||
if (bgThemeSel) bgThemeSel.value = bgTheme;
|
||
if (hwThemeSel) hwThemeSel.value = hwTheme;
|
||
|
||
if (fileInput) {
|
||
fileInput.addEventListener('change', function () {
|
||
clearError();
|
||
const file = fileInput.files && fileInput.files[0];
|
||
if (!file) return;
|
||
if (file.size > MAX_IMAGE_BYTES) {
|
||
showError('Image must be ≤1.5 MB (got ' + (file.size / 1024 / 1024).toFixed(1) + ' MB).');
|
||
fileInput.value = '';
|
||
return;
|
||
}
|
||
// Strict MIME whitelist matching the picker's
|
||
// accept attribute and the renderer-side validator
|
||
// in screen.js. Rejecting SVG/GIF/etc. here keeps
|
||
// the UI, the persisted bytes, and what the
|
||
// texture loader will accept all in agreement —
|
||
// and avoids any SVG-via-img surface area we
|
||
// don't need for a backdrop image.
|
||
// file.type can be empty on some OS/browser MIME
|
||
// tables (older Windows shells, some Linux desktops)
|
||
// even for valid .jpg / .png / .webp. Mirror the
|
||
// video section's extension fallback so a working
|
||
// browser isn't rejected on either side.
|
||
const extOk = /\.(jpe?g|png|webp)$/i.test(file.name || '');
|
||
const typeIsKnown = file.type && file.type !== 'application/octet-stream';
|
||
const typeOk = typeIsKnown ? ALLOWED_IMAGE_MIMES.has(file.type) : extOk;
|
||
if (!typeOk) {
|
||
showError('File must be a JPG, PNG, or WebP image.');
|
||
fileInput.value = '';
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onerror = function () {
|
||
showError('Could not read file.');
|
||
fileInput.value = '';
|
||
};
|
||
reader.onload = function () {
|
||
const dataUrl = reader.result;
|
||
if (typeof dataUrl !== 'string') {
|
||
showError('Unexpected file content.');
|
||
fileInput.value = '';
|
||
return;
|
||
}
|
||
if (dataUrl.length > MAX_DATA_URL_CHARS) {
|
||
// Pre-decode reject when the encoded form
|
||
// is plainly too large for storage. Avoids
|
||
// a confusing "applied but won't persist"
|
||
// message when the image is so large that
|
||
// localStorage could never have held it.
|
||
showError('Image is too large after encoding to save. Try a smaller file.');
|
||
fileInput.value = '';
|
||
return;
|
||
}
|
||
// Probe the bytes by attempting to decode them.
|
||
// Catches mis-typed files (e.g. .txt renamed to
|
||
// .png) and corrupt uploads BEFORE we persist
|
||
// them, so the user gets a clear error instead
|
||
// of a silent broken backdrop.
|
||
const probe = new Image();
|
||
probe.onload = function () {
|
||
// Decompression-bomb guard. Decoded
|
||
// dimensions are only known here, after
|
||
// probe.onload — small files can hide
|
||
// enormous pixel counts. Reject before
|
||
// persisting OR triggering the renderer
|
||
// load, so we never get past the data URL
|
||
// sitting in localStorage to the GPU
|
||
// texture upload that would crash the tab.
|
||
const w = probe.naturalWidth || 0;
|
||
const h = probe.naturalHeight || 0;
|
||
if (w > MAX_IMAGE_DIM || h > MAX_IMAGE_DIM || (w * h) > MAX_IMAGE_PIXELS) {
|
||
showError('Image dimensions are too large (' + w + '×' + h + '). Max ' + MAX_IMAGE_DIM + '×' + MAX_IMAGE_DIM + '.');
|
||
fileInput.value = '';
|
||
return;
|
||
}
|
||
if (window.h3dBgSetCustomImage) {
|
||
window.h3dBgSetCustomImage({ dataUrl: dataUrl, name: file.name });
|
||
}
|
||
// The setter swallows localStorage write
|
||
// failures (sets in-memory fallback so the
|
||
// current session still works) — re-read
|
||
// and verify to surface a real "won't
|
||
// persist" warning when the bytes didn't
|
||
// make it to disk. The image is still
|
||
// applied for this session in either case,
|
||
// so we always refresh the UI.
|
||
refreshImageOptionState(true, file.name);
|
||
fileInput.value = '';
|
||
let persisted = false;
|
||
try { persisted = localStorage.getItem('h3d_bg_customImageDataUrl') === dataUrl; } catch (_) { /* storage blocked */ }
|
||
if (!persisted) {
|
||
showError('Image applied for this session, but could not be saved (storage may be full or blocked). It will be lost on refresh.');
|
||
}
|
||
};
|
||
probe.onerror = function () {
|
||
showError('Image could not be decoded. Try a different file.');
|
||
fileInput.value = '';
|
||
};
|
||
probe.src = dataUrl;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', function () {
|
||
clearError();
|
||
if (window.h3dBgClearCustomImage) window.h3dBgClearCustomImage();
|
||
refreshImageOptionState(false, '');
|
||
if (fileInput) fileInput.value = '';
|
||
// If the picker was on `image`, fall back to a
|
||
// procedural style so the user isn't left with the
|
||
// (now-disabled) option selected.
|
||
if (sel && sel.value === 'image') {
|
||
sel.value = DEFAULTS.style;
|
||
if (window.h3dBgSetStyle) window.h3dBgSetStyle(DEFAULTS.style);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (videoFileInput) {
|
||
videoFileInput.addEventListener('change', function () {
|
||
clearVideoError();
|
||
const file = videoFileInput.files && videoFileInput.files[0];
|
||
if (!file) return;
|
||
if (file.size > MAX_VIDEO_BYTES) {
|
||
showVideoError('Video must be ≤50 MB (got ' + (file.size / 1024 / 1024).toFixed(1) + ' MB).');
|
||
videoFileInput.value = '';
|
||
return;
|
||
}
|
||
// file.type is empty or `application/octet-stream`
|
||
// in some browsers / OSes (older Windows shells,
|
||
// some Linux MIME tables) even for valid .mp4 /
|
||
// .webm. Fall back to the extension when the
|
||
// type isn't reported or is the generic
|
||
// octet-stream — matches the server-side fallback
|
||
// in routes.py so a working browser doesn't get
|
||
// rejected on either side.
|
||
const extOk = /\.(mp4|webm)$/i.test(file.name || '');
|
||
const typeIsKnown = file.type && file.type !== 'application/octet-stream';
|
||
const typeOk = typeIsKnown
|
||
? ALLOWED_VIDEO_MIMES.has(file.type)
|
||
: extOk;
|
||
if (!typeOk) {
|
||
showVideoError('File must be an MP4 or WebM video.');
|
||
videoFileInput.value = '';
|
||
return;
|
||
}
|
||
// POST to the plugin's upload route. routes.py
|
||
// streams the body with a server-side size guard
|
||
// and writes to disk under a deterministic
|
||
// single-slot filename (current.<ext>) — each
|
||
// upload replaces the previous file.
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fetch(VIDEO_UPLOAD_URL, { method: 'POST', body: fd })
|
||
.then(function (resp) {
|
||
if (!resp.ok) {
|
||
return resp.text().then(function (txt) {
|
||
// FastAPI error bodies are JSON
|
||
// like {"detail": ...}. detail can
|
||
// be:
|
||
// - string: "Only MP4 and WebM…"
|
||
// - array of objects (422
|
||
// validation errors): each
|
||
// item has msg / loc / type
|
||
// - plain object
|
||
// Stringify each shape into
|
||
// something readable so the error
|
||
// banner doesn't surface
|
||
// "[object Object]".
|
||
let msg = '';
|
||
try {
|
||
const detail = JSON.parse(txt).detail;
|
||
if (typeof detail === 'string') {
|
||
msg = detail;
|
||
} else if (Array.isArray(detail)) {
|
||
msg = detail
|
||
.map(function (d) { return (d && d.msg) || JSON.stringify(d); })
|
||
.join('; ');
|
||
} else if (detail && typeof detail === 'object') {
|
||
msg = JSON.stringify(detail);
|
||
}
|
||
if (!msg) msg = txt;
|
||
} catch (_) {
|
||
msg = txt || ('HTTP ' + resp.status);
|
||
}
|
||
throw new Error(msg);
|
||
});
|
||
}
|
||
return resp.json();
|
||
})
|
||
.then(function (data) {
|
||
// Lowercase the server response too — routes.py
|
||
// produces lowercase, but defending against a
|
||
// future server change or proxy munging the
|
||
// case is cheap and keeps the contract crisp.
|
||
const name = String((data && data.name) || '').toLowerCase();
|
||
if (!VIDEO_FILENAME_RE.test(name)) {
|
||
throw new Error('Server returned an unexpected filename.');
|
||
}
|
||
if (window.h3dBgSetCustomVideo) {
|
||
window.h3dBgSetCustomVideo({ name: name });
|
||
}
|
||
refreshVideoOptionState(true, name);
|
||
videoFileInput.value = '';
|
||
// _bgWriteGlobal intentionally swallows
|
||
// localStorage.setItem failures (full quota,
|
||
// blocked storage in private mode) and falls
|
||
// back to the in-memory _bgMemFallback, so
|
||
// setCustomVideo never throws even when
|
||
// persistence failed. Re-read to verify;
|
||
// surface a session-only warning that
|
||
// matches what the image flow does. The
|
||
// video itself works for this session
|
||
// because the renderer reads from the
|
||
// mem-fallback ahead of localStorage.
|
||
let persisted = false;
|
||
try { persisted = localStorage.getItem('h3d_bg_customVideoName') === name; } catch (_) { /* storage blocked */ }
|
||
if (!persisted) {
|
||
showVideoError('Video applied for this session, but the filename pointer could not be saved (storage may be full or blocked). It will be lost on refresh.');
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
showVideoError('Upload failed: ' + (err && err.message ? err.message : err));
|
||
videoFileInput.value = '';
|
||
});
|
||
});
|
||
}
|
||
|
||
if (videoClearBtn) {
|
||
videoClearBtn.addEventListener('click', function () {
|
||
clearVideoError();
|
||
// Slot-level clear: DELETE on the upload endpoint
|
||
// (no filename in the path) removes every
|
||
// current.* the upload dir holds. This matches
|
||
// the slot's design intent — the dir is a single
|
||
// logical asset slot, not a list of named files,
|
||
// so clear should empty it regardless of which
|
||
// extension currently lives there.
|
||
fetch(VIDEO_UPLOAD_URL, { method: 'DELETE' })
|
||
.then(function (resp) {
|
||
// Defense: surface non-2xx errors so a
|
||
// future server change to the contract
|
||
// (auth, rate limit, etc.) doesn't go
|
||
// unnoticed. Today's routes.py is
|
||
// best-effort and always 200.
|
||
if (!resp.ok) {
|
||
throw new Error('HTTP ' + resp.status);
|
||
}
|
||
return resp.json().catch(function () { return {}; });
|
||
})
|
||
.then(function (data) {
|
||
// Soft warning when the server kept any
|
||
// file (Windows file lock, antivirus, an
|
||
// in-flight GET) — the next upload's
|
||
// pre-cleanup will retry, but flag it so
|
||
// the user knows operator-side cleanup
|
||
// may be needed.
|
||
const leftover = (data && data.leftover) || [];
|
||
if (leftover.length > 0) {
|
||
showVideoError('Settings cleared, but server could not remove: ' +
|
||
leftover.map(function (l) { return l.name; }).join(', '));
|
||
}
|
||
// Clear local state only on confirmed server
|
||
// success. On failure we preserve the localStorage
|
||
// key so the renderer doesn't lose the reference to
|
||
// a file that still exists on disk and the user can
|
||
// retry the clear later.
|
||
if (window.h3dBgClearCustomVideo) window.h3dBgClearCustomVideo();
|
||
refreshVideoOptionState(false, '');
|
||
if (videoFileInput) videoFileInput.value = '';
|
||
if (sel && sel.value === 'video') {
|
||
sel.value = DEFAULTS.style;
|
||
if (window.h3dBgSetStyle) window.h3dBgSetStyle(DEFAULTS.style);
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
// Network failure or non-2xx. Do NOT clear the
|
||
// localStorage key — the file still exists on the
|
||
// server and removing the pointer would strand it,
|
||
// disabling the Custom video option until the user
|
||
// re-uploads. Surface the error so the user knows
|
||
// to retry.
|
||
showVideoError('Server clear failed (' +
|
||
(err && err.message ? err.message : err) +
|
||
'). Video settings unchanged — try again.');
|
||
});
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
</div>
|