feedBack/plugins/highway_3d/settings.html
ChrisBeWithYou 290783b80b
feat(highway_3d): hit-feedback juice + Hit-sparks toggle (#618)
* 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>
2026-06-28 11:53:48 +02:00

1817 lines
109 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 (&le;1.5&nbsp;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 (&le;50&nbsp;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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 112.</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 &amp; 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 &lt;anchor&gt;, 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>