Clean release snapshot

This commit is contained in:
byrongamatos
2026-06-16 18:47:13 +02:00
commit 6c110398b4
574 changed files with 162566 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
# 3D Highway Plugin — AI Maintainer Guide
This guide tells future AI assistants where each visual element lives in `screen.js`, what controls it, and the gotchas to watch for. The goal is for small polishes (color tweaks, sizing, animation timing, add/remove a label) to land in the right place on the first try without grep spelunking.
The whole renderer is **one file**`screen.js`, wrapped in an IIFE, registered as `window.slopsmithViz_highway_3d` (a slopsmith#36 setRenderer factory). No imports beyond Three.js loaded from the vendored `/static/vendor/three/three.module.min.js` (pinned r170; swapped from CDN when bundled into core).
**Styling (slopsmith `styles` capability).** This plugin owns its Tailwind CSS: it ships `assets/plugin.css` and declares `"styles": "assets/plugin.css"` in `plugin.json`, so core's prebuilt `static/tailwind.min.css` no longer scans it (it's excluded from core's content globs). The frontend injects `assets/plugin.css` as a `<link>` when the renderer activates. This is the one maintainer-time build step: after you add/change a Tailwind class in `screen.js` or `settings.html`, run `bash build-tailwind.sh` (pinned `tailwindcss@3.4.19`, `corePlugins.preflight=false` — utilities only) and **bump the `version` in `plugin.json`** so the injected `<link>`'s `?v=` cache-buster fetches the fresh file. The generated `assets/plugin.css` is committed; end users never build. See [docs/plugin-styles.md](../../docs/plugin-styles.md).
> **Navigation note:** This guide references functions by name and uses the existing banner comments (`/* ── Scene initialisation ─ */`, etc.) as section anchors. Line numbers are deliberately avoided so this stays correct as the file evolves. Use `Grep` for the function name or banner text to jump to a section.
## File structure at a glance
The file is laid out top-to-bottom as:
1. **Constants block** — palette (`S_COL`), scale (`SCALE`, `K`), fret/string counts, geometry sizes, camera, fog
2. **Pure helpers**`fretX`, `fretMid`, `dZ`, `computeBPM`
3. **Three.js loader**`loadThree()` (loads vendored `/static/vendor/three/three.module.min.js`, memoized)
4. **Splitscreen helpers**`_ssActive`, `_ssIsCanvasFocused` (read `window.slopsmithSplitscreen`)
5. **`createFactory()`** — the rest of the file is one big closure
- Per-instance state (Three.js refs, pools, camera state, lifecycle flags)
- `txtMat()` text-sprite cache, `pool()` factory
- `drawChordDiagram()` — 2D canvas chord diagram (top-left overlay)
- `drawLyrics()` — 2D canvas lyrics renderer (top centre)
- `initScene()` — one-time WebGL setup: scene, camera, lights, materials, pools
- `buildBoard()` — static fretboard geometry: strings, fret wires, fret dots, board plane
- `updateStringHighlights()` — per-frame string emissive glow + opacity
- `update(bundle)` — the big per-frame function: notes, chords, beats, lane, fret labels
- `drawNote()` — single note: outline, body, sustain, drop line, technique labels, projection
- `camUpdate()` — smooth camera lerp + self-correcting NDC look-at
- `applySize()` — DPR + canvas size + aspect clamping
- `teardown()` — dispose all GPU resources + reset state
- `canvasSize()` — resilient canvas-dimension lookup
- **Returned API** — `init / draw / resize / destroy` (setRenderer contract)
## Coordinate system
- **+X** runs along the fretboard (low frets → high frets, `fretX(f)` and `fretMid(f)`).
- **+Y** is up (string Y is `sY(s)`, low strings have lower Y when not inverted).
- **+Z** is toward the camera. Notes spawn at negative Z and approach Z=0 (the hit line). Past notes would be at positive Z, but `noteZ` is clamped via `Math.min(0, dZ(dt))` in `drawNote()` so they stop at the string plane.
- **Camera** sits at roughly `(curX + 20*K, h*0.95, dist*0.75)` — positive Z, slightly above and behind the play line, looking toward `(curX, curLookY, -FOCUS_D * 0.35)`.
`dZ(dt) = -dt * TS` — the closer to "now," the closer to Z=0. `TS = 200*K` is the world-units-per-second scroll rate.
## The K scale and why everything is multiplied by it
`SCALE = 2.25`, `K = SCALE / 300 ≈ 0.0075`. **Almost every world-space dimension is expressed as `N * K`** so the whole scene scales as one unit. Tweaking `SCALE` alone resizes the entire highway. If you change a literal world dimension, write it as `N * K` to keep it consistent — naked numeric literals in Three.js geometry creation calls (e.g. inside `BoxGeometry`) are an obvious smell.
Concrete sizes (search the constants block for the names):
| Const | Value (world units) | Meaning |
|---|---|---|
| `STR_THICK` | `0.25 * K` | String thickness |
| `S_BASE` / `S_GAP` | `3 * K` / `4 * K` | Lowest-string Y / inter-string gap |
| `NW`, `NH`, `ND` | `5 * K`, `3 * K`, `0.5 * K` | Note width / height / depth |
| `TS` | `200 * K` | Scroll speed (world units per second) |
| `AHEAD` / `BEHIND` | `3.0` / `0.5` | Seconds visible ahead / behind hit line |
| `CAM_DIST_BASE` / `CAM_H_BASE` | `240 * K` / `150 * K` | Reference camera distance / height |
| `FOG_START` / `FOG_END` | `200 * K` / `670 * K` | Fog kicks in past hit line, swallows by the horizon |
## "I want to change X" — quick lookup
Each entry names the function or banner you should grep for, plus key sub-blocks (also marked with banner comments inside the function).
### Strings
- **String colors** → `S_COL` array in the top-level constants block. Eight-element vibrant palette; index `s` is the string (0 = high E for guitar). `MAX_RENDER_STRINGS` keys off `S_COL.length`.
- **String count for the active arrangement** → `resolveStringCount(bundle)` (top-level helper). Reads `bundle.stringCount` (slopsmith#93) with a `bass`-name fallback. Don't reintroduce `tuning.length` — see Pitfall #4.
- **String thickness / gap / base Y** → `STR_THICK`, `S_BASE`, `S_GAP` constants.
- **String-to-Y mapping (respects invert)** → the `sY(s)` arrow function inside `createFactory()`. Single source of truth for "where on Y is string s."
- **Static string mesh creation** → `buildBoard()`, the `// Thin Line strings (glow layer)` and `// BoxGeometry strings — emissive glow ...` comment blocks. Two layers: low-opacity `Line` for soft glow, `BoxGeometry` mesh per string with its own material clone (kept in `stringLines[]` for live emissive updates).
- **Live string glow / pulse** → `updateStringHighlights(noteState)`. Tunables: `BASE_GLOW`, `MAX_GLOW`, `IDLE_OP`. Driven by `noteState.stringSustain` and `noteState.stringAnticipation`.
### Fretboard
- **Fret count** → `NFRETS` constant. Increasing requires nothing else.
- **Fret X positioning** → `fretX(f)` and `fretMid(f)` (top-level helpers). Logarithmic guitar-fret spacing within `SCALE`.
- **Fretboard plane / fret wires / fret dots** → `buildBoard()`, separate banner-style comment blocks (`// Fret wires`, `// Fret dots`). The dark background plane is the first thing built; main fret wires use `0xbbbbff` / opacity 0.8, minor wires `0x666688` / opacity 0.4. Single/double dots: `DOTS` array + `DDOTS` set in the constants block.
- **Fret-row label colors / sizing** (the heat-coloured row of fret numbers below the board) → `update()`, `// ── Dynamic fret number row ──` block. Active = `#ffe84d`, inactive = `#9ab8cc`, opacity / scale driven by `noteState.fretHeat[f]`. Text rendering (font, outline, shadow) is governed by the `'fretRow'` preset in `TXT_STYLES` — see "Tweaking text-sprite styling".
- **Active-fret cooldown** → `FRET_COOLDOWN` constant. How long after the last note in a fret it stays in the active set.
### Notes
- **Single-note rendering** → `drawNote()`. Handles outline, core body, open-string variant, sustain trail, lane drop line, all technique labels, fret connector label, and the board projection. Each visual block has its own banner comment (`// ── Outline ──`, `// ── Core (filled note body) ──`, `// ── Sustain trail ──`, `// ── Lane drop line ──`, `// ── Technique labels ──`, `// ── Per-note fret connector label ──`, `// ── Board projection ──`).
- **Note geometry / size** → `gNote = new T.BoxGeometry(NW, NH, ND)` in `initScene()`. Per-note scale tweaks happen inside `drawNote()`.
- **Note approach rotation (vertical → horizontal)** → search `approachRot` inside `drawNote()`. Maps `dt / AHEAD` to `[0, π/2]`. Open strings skip the rotation.
- **Note color** → `mStr[s]` (idle) / `mGlow[s]` (hit), built in `initScene()`. Hit material is white-with-emissive, idle is dim emissive of the string color.
- **Sustain trail** → `// ── Sustain trail ──` block in `drawNote()`. Geometry: scaled `gSus` (`BoxGeometry(1,1,1)`). Width `NW * 0.85`, height `NH * 0.12`. Outline mesh + colored core mesh.
- **Lane drop line** → `// ── Lane drop line ──` block in `drawNote()`. Vertical line from each upcoming note down to the fretboard plane in the string's color.
- **Per-note fret connector label** → `// ── Per-note fret connector label ──` block in `drawNote()`. Number below the board with a thin line up to the note. Be careful with `replace_all` on the `0.5` and `0.4` floats in the alpha formula — they're separate constants. Uses the `'noteFret'` preset in `TXT_STYLES` (also applied to the on-body fret number when `showFretOnNote` is enabled).
- **Technique markers** (bend, slide, hammer/pull/tap, accent, tremolo, palm-mute, pinch harmonic) → `// ── Technique labels ──` block in `drawNote()`. Most are small if-blocks using `txtMat(text, color, wide, style)` (cached sprite material; `'technique'` preset in `TXT_STYLES`). Exceptions: a **bend** draws a string-coloured chevron strength stack (`bendChevronMat`, one chevron per half-step), and **hammer-on / pull-off** draw a white ▲/▼ triangle with a string-coloured border (`triMat`) — both pinned to the gem; the bend ribbon's up→hold→down contour is driven by `bendSemisAtTime`.
- **Open-string note** → special-cased throughout `drawNote()`: `n.f === 0`. Wider/flatter geometry, "0" label sprite, uses `openX` (the chord's open-string centroid) when supplied.
- **Board projection ("ghost" preview)** → `// ── Board projection ──` block in `drawNote()`. Two meshes per string (`projMeshArr`, `projGlowArr`), one visible per frame for the next note. Linger window `PROJ_WIN`. Gated on the `projectionVisible` setting (BG_DEFAULTS / `h3dBgSetProjectionVisible` / the "Show note preview on the fretboard" checkbox in `settings.html`) — when off, the block is skipped and `update()`'s per-frame `m.visible = false` reset leaves the ghost hidden. **The glow has `renderOrder = -1`** which fights the strings — see Pitfall #6.
- **Note-hit "sizzle" (slopsmith#254)** → `drawNotedetectSizzle()` (called from the `lyricsCtx` block in `draw()`, just before `drawNotedetectLabels()`). For each confirmed hit/active note (`_ndGood` in `drawNote()` pushes `{x, y, z, s, alpha, color}` onto the per-frame `_ndSizzle` array — `alpha` is the provider's clamped fade, `color` an optional palette override), it projects the note's world point through the up-to-date `cam`, sizes the burst from a fretboard-X-axis offset projection (reliable even when the note's rotated flat at the line), and twinkles a few short crackling ellipse-arc segments + tiny dots hugging the note's rectangle — re-randomised every frame, contained to ≲1.4× the note, half white / half the string colour (or the provider's `color` when given). Every dot/arc's `globalAlpha` and `shadowBlur` are scaled by the entry's `alpha`, and the per-element "off-this-frame" probability rises as `alpha` decays, so a struck-note glow visibly thins and fades. Also: `_ndGood` swaps the note's outline to `mGlow[s]` (bright string-tinted, not green). Knobs are inline: arc/dot count, base on-probability, line widths, `shadowBlur`, spread radii. Lives entirely on the 2D overlay layer — no Three.js geometry/disposal.
### Chords
- **Chord rendering loop** → `update()`, `// ── Chords ──` block. Iterates `bundle.chords`, calls `drawNote()` per chord-note, then draws the frame box, name label, and barre indicator.
- **Chord linger after hit** → the `0.55`-second value passed as the `linger` arg to `drawNote()` from inside the chord loop, and used in the chord-frame Z clamp + opacity formulas.
- **Chord frame-box** (rectangle around frets in the chord) → inside the chord loop, search for the `drawEdge` helper. Four edges + a low-opacity fill. `isRepeat` halves the height + dims it.
- **Chord name label (gold)** → in the same chord loop, search `chordName`. Cached via `txtMat(chordName, '#e8d080', true)`. Anchored above the chord box.
- **Barre indicator** (white vertical line at the barre fret during linger) → in the chord loop, gated on `/barre/i.test(chordName) && chDt <= 0`. Position is `fretMid(bFret)` where `bFret` is the lowest fretted string.
- **Repeat-chord detection** → `prevChordSig` / `prevChordTime` inside the chord loop. Same shape within 0.5 s → `isRepeat = true` (suppresses note bodies, dims frame).
- **Chord diagram (top-left 2D overlay)** → `drawChordDiagram()`, called from the `lyricsCtx` block at the bottom of the returned `draw()`. The chord-to-display is selected in `update()` under `// ── Chord diagram: track most recently hit chord ──` and stashed in `_diagChord` (most recently hit named chord within the 0.55 s linger window).
### Camera
- **Reference values** → `CAM_H_BASE`, `CAM_DIST_BASE`, `REF_ASPECT`, `FOCUS_D`, `CAM_LERP_BASE` in the constants block.
- **Smooth lerp + look-at** → `camUpdate()`. BPM-scaled lerp speed (`CAM_LERP_BASE * bpm/120`).
- **Self-correcting framing** → bottom half of `camUpdate()`. Projects the fretboard mid-Y to NDC, nudges `tgtLookY` until that point sits at NDC Y ≈ `DESIRED_NDC_Y` (lower third of frame). This is what lets the camera adapt automatically to ultra-wide split-screen panels.
- **Aspect compensation** → `aspectScale = Math.max(1, REF_ASPECT / Math.max(cam.aspect, 0.5))` in `applySize()`. Clamped to ≥ 1 so wide panels keep baseline depth (don't dolly in flat). Removing the `Math.max(1, …)` is the bug we already fixed; don't reintroduce it.
### Beats and sections
- **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats.
- **Section labels** → `update()`, `// ── Section labels ──` block. Cyan (`#00cccc`) sprite at fret 12, above the highest string.
### Highway lane (the highlighted strip under active frets)
- **Lane drawing** → `update()`, `// ── Dynamic highway lane ──` block. `pLane` is a single quad on the fretboard plane; `pLaneDivider` is thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets.
- **Lane intensity** → `highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it). `_laneTargetColor = 0x4488ff` (set in `initScene()`) is the "lit" color, blended toward from `0x112233`.
### Lyrics & overlays
- **Lyrics overlay** → `drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim).
- **Chord diagram overlay** → `drawChordDiagram()` (see "Chords" above). 2D canvas, top-left, fades over the 0.55 s linger window. Respects `inverted` (column 0 is high-e when inverted, low-E otherwise).
- **The `lyricsCanvas`** is created in `initScene()` with `z-index:1`, appended to `wrap` **after** `ren.domElement` — this is the empirically-correct stacking order for all browsers/contexts (including splitscreen panels with `position:relative; overflow:hidden`). Don't reorder; see Pitfall #5.
### Splitscreen
- **Focus dim** → `_isFocused` flag, manipulated by `_updateFocusState()`. Fades ambient + directional light intensity in non-focused panels.
- **Per-panel resize fallback** → search `_lastHwW` in the returned `draw()`. The renderer self-detects when the highway canvas backing-store dimensions change and re-runs `applySize()`. Needed because the splitscreen plugin overrides `hw.resize` and never calls `renderer.resize()`.
- **Reduced DPR in split** → `applySize()` clamps DPR to 1.25 when splitscreen is active vs 2 otherwise (search `baseDPR`). Keeps four-panel quad layout from melting GPUs.
### Splitscreen panel controls/settings
- Per-panel background overrides use `localStorage` keys shaped as `h3d_bg_panel<N>_<key>`. When present, they override the global `h3d_bg_<key>` value for panel `N`; when absent, the global value still applies.
- Keep per-panel keys to `BG_DEFAULTS` entries that `_bgLoadSettings()` reads. Do not add panel-only keys outside that load path.
- `panelControls` is a static, host-readable, curated descriptor list for controls a host can expose per panel. It documents the supported per-panel surface; the renderer still loads values through `_bgLoadSettings()`.
- Asset/background image keys remain global-only. Do not make uploaded or selected asset references panel-scoped unless that contract is explicitly widened.
- Host refresh nudges that call toggle setters must pass real booleans, not strings such as `'false'`, so setters can distinguish `true` from `false`.
## The `bundle` object
Every per-frame renderer call receives a `bundle` from slopsmith core. Fields used by this plugin:
- `currentTime` — playback time in seconds (drives `dt` for everything)
- `notes`, `chords`, `beats`, `sections` — chart arrays (already difficulty-filtered by core)
- `chordTemplates` — array indexed by `ch.id`; each `{ name, frets: [N] }`
- `lyrics` — syllable array `[{ w, t, d }, …]`
- `inverted` — display flag honored via `sY(s)` (low-string-on-top vs the default low-string-on-bottom)
- `lyricsVisible` — gate for lyrics overlay
- `renderScale` — pixel-ratio multiplier from the user's quality setting
- `songInfo.arrangement` — only field of `songInfo` this plugin reads, used as the bass-name fallback in `resolveStringCount()`
- `stringCount` — slopsmith#93; always prefer this over deriving from tuning/arrangement
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
- `getNoteState(note, chartTime)` — slopsmith#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'``mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'``mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
`tuning` and `capo` aren't consumed by this plugin.
### Score FX (notedetect game-scoring layer)
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
- **Session FX** → `notedetect:fx` events (`{ fxType: 'multiplier'|'milestone'|'streakBreak', ... }`). notedetect dispatches each detail object twice in the same task: on `window` (unscoped, first) and as a bubbling CustomEvent from its per-panel instanceRoot (scoped, second). The listener (`_fxOnFx`, bound with the other notedetect listeners) treats element-targeted copies as authoritative — accepted only when their root lives in this panel's container — and **defers the window copy by a task** (`setTimeout 0`): if the element copy (same detail reference) arrived meanwhile it's dropped as a duplicate, otherwise it's the compat fallback for a detector whose root isn't in the DOM. This keeps splitscreen panels from rendering each other's FX even for the first event of a session. Effects: milestone → particle burst from a 4-slot Float32Array pool (`_fxBursts`), multiplier tier-up → expanding ring pulse at the strike-line centre, streak break → brief red wash.
- **Skin palette** → `_fxResolvePalette()` reads `localStorage['slopsmith_notedetect_skin']` (`neon`/`esports`/`metal``_FX_PALETTES`) at listener-bind time and on the `notedetect:skin` bus event. The display fonts are document-loaded by notedetect's stylesheet, so the overlay canvas can reference the family names directly.
- Everything lives on the 2D overlay layer — no Three.js geometry, no `txtMat()` cache traffic, nothing to dispose; `teardown()` deactivates the pools and removes both listeners.
- **This block is the reference implementation for other renderer plugins** (drum highway, piano, custom highways) that want score pops / session FX: copy the `_fxOnFx` dedup+scoping listener, the `popKey`-keyed seen-map (cleared on backward seek), and the `_FX_PALETTES` skin mapping. The full consumer contract (events, payloads, provider verdict fields, theming variables) is documented in slopsmith-plugin-notedetect's `CLAUDE.md`.
If you need a bundle field that isn't here yet, check `_makeBundle()` in `static/highway.js` in the **slopsmith core repo** — this is the plugin repo, `static/highway.js` is not here. The full path in the parent slopsmith checkout is `slopsmith/static/highway.js`.
## Per-string state arrays
Several frame-local arrays are sized to `nStr`:
```js
const noteState = {
stringSustain: new Array(nStr).fill(false),
stringAnticipation: new Array(nStr).fill(0),
fretHeat: new Array(NFRETS + 1).fill(0),
strGlow: new Array(nStr).fill(0.5),
};
```
Anything that indexes a per-string array MUST be guarded by `validString(s)`. The function checks that `s` is an integer in `[0, nStr)` (returning `false` otherwise so the caller can skip), warns once when an out-of-range index is seen, and keeps the `mStr / mGlow / mSus / projMeshArr` lookups safe. It does NOT clamp — out-of-range strings are dropped, not silently mapped to a valid one. `filterValidNotes(notes)` is the chord-note equivalent (allocates only when something would actually be dropped).
## Object pools
Pools live as closure refs (`pNote`, `pSus`, `pLbl`, `pBeat`, `pSec`, `pFretLbl`, `pLane`, `pLaneDivider`, `pChordBox`, `pChordLbl`, `pBarreLine`, `pNoteFretLabel`, `pConnectorLine`, `pDropLine`, `pSusOutline`).
The pool factory `pool(parent, mk)` returns `{ get(), reset() }`. **Every pool MUST be `.reset()`-ed at the top of `update()`** — otherwise objects from the previous frame stay visible. When you add a new pool, add the reset call too. Search for the existing block of `.reset()` calls at the top of `update()` to find where to add yours.
If a pool's mesh has per-instance state (its own material clone, its own texture map), set those fields each `get()` call so a recycled instance picks up the right values. The "first context wins" trap is real — recycled sprites that retain a stale `material.map` from a previous frame won't repaint. The chord-name label loops on this (search `lbl.material.map !== mat.map`) by checking before swapping.
## Key gotchas / pitfalls
1. **Adding a new pool? Reset it.** The reset block at the top of `update()` is easy to miss when adding a new pool elsewhere.
2. **`txtMat()` is cache-keyed by `(style, text, color, wide)`.** Calling it with a numeric `text` works (it's coerced via `String(...)`), but new label content creates a new texture forever. Don't generate dynamic per-frame text (e.g. interpolated values) through `txtMat()` or you'll leak GPU memory. For static labels that change occasionally (chord names, fret numbers), the cache is fine. The `style` arg picks a preset from the `TXT_STYLES` table — see "Tweaking text-sprite styling" below.
3. **Disposal in `teardown()` matters.** Three.js doesn't garbage-collect GPU resources. Every `material.dispose()`, `geometry.dispose()`, `map.dispose()`, and `ren.dispose()` call there is load-bearing. `teardown()` is called from `init()` (when re-initing), `destroy()` (setRenderer swap or `highway.stop()`), and on init failure.
4. **Don't use `tuning.length` for string count.** `bundle.tuning` (and `arr.tuning` server-side) is always 6 elements even for bass — slopsmith pre-fills the array with zeros for unused strings. Use `bundle.stringCount` (slopsmith#93), with `/bass/i.test(arrangement)` as the only acceptable fallback. There's a comment in `resolveStringCount()` documenting this.
5. **lyricsCanvas DOM order.** The 2D overlay canvas is appended to `wrap` AFTER `ren.domElement` and given `z-index:1`. This is the empirically-correct order — earlier versions had it before the WebGL canvas, which broke in splitscreen panels with `position:relative; overflow:hidden`. Don't reorder without testing both modes.
6. **Projection glow `renderOrder = -1`** in `initScene()`. This is a known-suboptimal setting — it forces the glow to draw before the strings in the transparent queue, so the string visibly cuts through the preview. Removing the line lets natural Z-sort layer it correctly. Plus the projection's world-Y matches the string Y, which after perspective projection puts the preview slightly screen-lower than the string; bumping `projY = y + NH * 0.4` recenters it. (Both fixes live on the `fix/preview-stacking` branch.)
7. **`renderOrder` on transparent objects is sticky.** Three.js sorts the transparent queue by `renderOrder` first, then back-to-front. A stray `m.renderOrder = -1` on something will pull it under everything regardless of Z. When in doubt, leave `renderOrder` at the default 0 and rely on Z position.
- **Corollary: `depthTest: false` alone does NOT make a sprite "always on top."** It removes the sprite from depth-buffer comparison, but draw order in the transparent queue is still determined by `renderOrder` then Z. Anything rendered after a `depthTest: false` sprite will still overdraw it. For HUD-style overlays that must always be visible (fret-row labels — issue #35, technique callouts), set `renderOrder = 1000` AND keep `depthTest: false`. Both knobs together is the contract; either alone leaves the door open to occlusion.
8. **`ch.id` may be missing.** Some chord events lack an `id` (or it doesn't index into `chordTemplates`). Always optional-chain: `bundle.chordTemplates?.[ch.id]?.name`. The chord diagram + name label both gate on a non-empty result.
9. **The `aspectScale` clamp (`Math.max(1, …)`).** Without it, ultra-wide split-screen panels (top/bottom layout, ~5:1 aspect) yield aspectScale ≈ 0.33, which dollies the camera way in and kills highway depth. The clamp keeps wide panels at baseline depth and only allows narrow panels to dolly the camera back.
10. **The `_oobStringWarned` flag is reset on `nStr` change** in the returned `draw()` — switching from guitar (6) to bass (4) re-arms the warning so a malformed bass chart still gets logged.
11. **`renderOrder` values for the lane and dividers are explicit** in `update()` (`lane.renderOrder = 1`, `div.renderOrder = 2`). The lane plane needs to draw above the static fretboard plane (which has no renderOrder), and dividers need to draw above the lane.
## Tweaking colors safely
The eight-color palette `S_COL` is the single source of truth for per-string color. **Don't hardcode hex values inside `drawNote()` or `update()`** — every per-string color reference is either an entry in `S_COL` or one of the per-string material arrays (`mStr`, `mGlow`, `mSus`, `mProj`, `mProjGlow`) built from it.
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
Non-string colors (lane target `0x4488ff`, fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
## Tweaking text-sprite styling
Every text label in the 3D scene is rasterised by `txtMat(text, color, wide, style)` and the look (font, outline, drop-shadow, source-canvas resolution) is driven by a preset in the `TXT_STYLES` table at the top of `createFactory()`. **Do not edit the body of `txtMat()` to change a single label class** — change the relevant preset entry instead, so the rest stay unaffected.
Current presets and their callers:
| Preset | Used by | Default look |
|---|---|---|
| `fretRow` | Fret-number row under the board (`update()`, fret-row block) | Arial Black 900, 256px source canvas, 18px dark outline + soft drop-shadow — designed to pop against any background |
| `noteFret` | Per-note connector numbers + on-body fret label (`drawNote()`) | Same heavy treatment as `fretRow` |
| `chord` | 3D chord-name labels above chord boxes | bold sans, 128px source, 6px outline (lighter so the gold reads) |
| `section` | Section banners ("Verse", "Chorus") at fret 12 | bold sans, 128px source, 6px outline |
| `technique` | Bend / slide / H / P / T / PH / PM / accent / tremolo / open-string overlay | bold sans, 128px source, 6px outline |
| `open` | The "0" label on open-string note bodies | bold sans, 128px source, 6px outline |
Style fields:
- `font` / `wideFont` — full CSS font shorthand (weight + size + family); `wideFont` is used when `wide=true` (long-aspect labels: chord names, section names, "↑1/2", "~~~"). Keep both in sync if you change weight or family.
- `srcH` — source-canvas height in px. Wide labels use `srcH * 4` for width. Larger `srcH` keeps glyph strokes crisp after bilinear downsampling onto small sprites — bumping it from 128 → 256 was the difference between thin-and-blurry and crisp on the fret-number presets. **Keep `srcH` power-of-two** (128, 256, 512, …): WebGL1 and Three.js silently disable mipmap generation on NPOT textures and fall back to a non-mipmap min-filter, which causes shimmer/aliasing on labels far down the highway. The 4× width derivation preserves POT-ness too (e.g. 256 → 1024 wide).
- `stroke` / `strokeW` — outline color and line-width in source-canvas px. Set `stroke: null` or `strokeW: 0` to skip the outline (faster cache rasterisation, no contrast halo).
- `shadow``{ color, blur, dx, dy }` or `null`. Drawn via canvas 2D `shadowColor` / `shadowBlur` / `shadowOffsetX/Y` *before* the stroke and fill, so it haloes the whole glyph.
**Cache key includes the preset name** (`style|wide|text|color`), so two presets with otherwise-identical text produce two distinct cached materials. Adding a new preset is safe — just add the entry to `TXT_STYLES` and pass its name as the 4th arg at the call site. Forgetting to pass `style` falls back to `'technique'` (the broadest, most generic preset) and is the right default for a brand-new label class.
**Don't generate per-frame distinct text through `txtMat()`** (e.g. interpolated values, tick counters). The cache is unbounded and will leak GPU memory across the session — see Pitfall #2.
## Lifecycle (setRenderer contract)
Per slopsmith#36, the factory returns `{ init, draw, resize, destroy }`:
- **`init(canvas, bundle)`** tears down any prior state, sets `highwayCanvas`, lazily loads Three.js, runs `initScene()`, calls `applySize()` (with a `retrySize` rAF loop fallback if the canvas isn't laid out yet).
- **`draw(bundle)`** is gated on `_isReady`. Re-resolves `nStr` / inverted / renderScale, then `update(bundle) → camUpdate(bundle) → ren.render → 2D overlays`. The `_lastHwW/_lastHwH` check at the top auto-resizes when the splitscreen plugin bypasses `resize()`.
- **`resize(w, h)`** is gated on `_isReady`. Just calls `applySize()`.
- **`destroy()`** is idempotent. Sets flags, runs `teardown()`, drops `highwayCanvas`. Tolerates being called on an instance that's been destroyed and re-init'd already (resets `_lastHwW/H`, `_diagChord`, etc.).
The factory **returns a fresh instance per call**, so splitscreen's per-panel `setRenderer(slopsmithViz_highway_3d())` gets independent state per panel — important because the chord diagram, projection meshes, etc. are all per-instance.
## Branching / PR conventions
- Feature branches off `main`, descriptive name (e.g. `fix/preview-stacking`, `feat/palette-picker`).
- PR target: target the contributor's own fork by default unless they ask otherwise; confirm before opening a PR upstream. Run `git remote -v` in this directory to see the remotes that are configured locally.
- Commit messages: short imperative subject, optional body explaining *why*. Don't summarize the diff — the diff already does that.
- This plugin is bundled **in-tree** at `plugins/highway_3d/` inside the `byrongamatos/slopsmith` repository (not a gitlink/submodule). It ships with the default container image. Changes go through the normal slopsmith PR process — no separate upstream repo to sync.
## When in doubt
- `screen.js` is one file — `Grep` for the function name or banner text before guessing.
- The constants block at the top is intentionally exhaustive; scan it before introducing a new magic number.
- If a "polish" feels like it should be one or two lines but stretches into restructuring, double-check whether a per-frame state field, pool reset, or `validString()` guard already covers your case.
+86
View File
@@ -0,0 +1,86 @@
# 3D Highway — Free-Camera Bridge
> 🇬🇧 English · 🇪🇸 Español más abajo
## What this modification does (EN)
This change adds a small, **opt-in** hook inside `camUpdate()` in
[`screen.js`](./screen.js) that lets an external plugin drive the 3D Highway
camera (orbit, height, zoom, tilt, pan) **without forking the renderer**.
The renderer reads a single shared object once per frame:
```js
window.__h3dCamCtl = {
enabled, // master switch — when false the renderer auto-frames as usual
heightMul, // camera height multiplier
distMul, // dolly / zoom multiplier
yaw, // orbit around the look target (radians)
pitch, // tilt offset (highway K-units)
panX, panY // look-target pan (highway K-units)
};
```
**Safety / backward compatibility**
- The bridge object is read **once** (`_freeCam`) and reused for both the
position and the look-at transforms.
- Every field is coerced with `Number.isFinite` to a safe default
(`heightMul`/`distMul → 1`, everything else → `0`) before use, so a malformed
bridge object can **never** feed `NaN` into `cam.position.set` / `cam.lookAt`.
- When `window.__h3dCamCtl` is absent or `enabled === false`, behaviour is
**byte-for-byte identical** to before: the `if` is skipped and `lookAt` uses
the existing `else` path.
The shared `-FOCUS_D * 0.35` look-at Z is computed once (`_lookAtZ`) and reused.
## The plugin that uses this bridge
**Camera Director** — a floating, bilingual (EN/ES) control panel to author,
save and share highway camera views:
➡️ **https://github.com/nimuart/cameradirector_feedback**
Camera Director creates and writes `window.__h3dCamCtl`; this renderer only
reads it. That one-object contract is the entire integration surface — no other
globals, no patching of the renderer's internals.
---
## Qué hace esta modificación (ES)
Este cambio agrega un hook pequeño y **opcional** dentro de `camUpdate()` en
[`screen.js`](./screen.js) que permite que un plugin externo maneje la cámara del
3D Highway (órbita, altura, zoom, inclinación, paneo) **sin tener que forkear el
renderer**.
El renderer lee un único objeto compartido una vez por frame:
```js
window.__h3dCamCtl = {
enabled, // interruptor maestro — si es false, el renderer encuadra solo
heightMul, // multiplicador de altura
distMul, // multiplicador de dolly / zoom
yaw, // órbita alrededor del objetivo (radianes)
pitch, // inclinación (unidades K del highway)
panX, panY // paneo del objetivo (unidades K del highway)
};
```
**Seguridad / compatibilidad**
- El objeto se lee **una sola vez** (`_freeCam`) y se reutiliza para la posición
y para el look-at.
- Cada campo se valida con `Number.isFinite` y cae a un default seguro
(`heightMul`/`distMul → 1`, el resto → `0`), así un objeto mal formado **nunca**
mete `NaN` en `cam.position.set` / `cam.lookAt`.
- Si `window.__h3dCamCtl` no existe o `enabled === false`, el comportamiento es
**idéntico** al de antes.
## El plugin que usa este puente
**Camera Director** — panel flotante y bilingüe (EN/ES) para crear, guardar y
compartir vistas de cámara del highway:
➡️ **https://github.com/nimuart/cameradirector_feedback**
Camera Director crea y escribe `window.__h3dCamCtl`; este renderer solo lo lee.
Ese contrato de un solo objeto es toda la superficie de integración.
+38
View File
@@ -0,0 +1,38 @@
# 3D Highway
A 3D note highway visualization for [Slopsmith](https://github.com/byrongamatos/slopsmith) — an alternative to the default 2D highway, with a sense of depth and perspective inspired by stage views in modern rhythm games.
## What you get
- A camera-perspective highway with notes flying down toward a virtual fretboard at the bottom of the screen
- Glowing strings that pulse and brighten on each hit
- Note Detection feedback, including hit/miss outlines and diagnostic
early/late/sharp/flat labels when the note detection plugin emits enriched
judgments
- Chord frame-boxes, named-chord labels, and a chord diagram overlay (configurable corner position) so you can read shapes at a glance
- Two complementary barre indicators fire together when a barre chord shape is detected (2+ consecutive strings fretted at the lowest fret, e.g. F `[1,1,2,3,3,1]`, or an outer-edge full-span barre with every intermediate string fretted, e.g. B major `x24442`): a translucent vertical line across the strings on the 3D highway, and a straight bracket drawn inside the first fret space of the chord diagram overlay
- A heat-colored fret number row that lights up around your active playing region
- Selectable color palettes for the strings — pick the look you want
- Audio-reactive ambient background animations (particles, silhouettes, stage lights, geometric — pick one or turn it off)
- Lyrics overlay synced to the song
- Works as the main player view *or* per-panel inside the splitscreen plugin
## Install
3D Highway ships **bundled** with Slopsmith — no separate installation needed. Pick **3D Highway** from the visualization picker in the player.
> **Note:** The bundled version is preferred over any user-installed copy with the same plugin ID. If you have an old `slopsmith-plugin-3dhighway` clone on disk (from before 3D Highway was promoted to core), it will be ignored at startup — a warning in the server log names the path of the discarded copy. You can safely delete the stale clone.
>
> **Fallback:** In the unlikely event that the bundled copy fails to load its routes (e.g., a broken bundled release), Slopsmith will automatically fall back to your user-installed copy and show a yellow "Fallback" badge in the Settings panel. Check the server startup log for the root cause in that case.
## Settings
Most of the visual controls (background style, intensity, audio reactivity, color palette) live on Slopsmith's **Settings** screen under the *3D Highway* section.
## Contributing / development
For maintainers and AI assistants working on the codebase, see [`CLAUDE.md`](CLAUDE.md) — it's a navigation guide that maps every visual element to where it lives in `screen.js`, plus the gotchas worth knowing before tweaking.
### Perf bench (`?h3dbench=1`)
Append `?h3dbench=1` to the player URL to enable opt-in `console.log` reporting of `update()` self-time, broken into six segments — `frame` (everything between `pbBeg(0)` at the top of `update()` and `pbEnd(0)` at the bottom; excludes the trailing `pbReportTick()` logging that fires after `pbEnd(0)`), `state` (per-frame state-derivation loop), `next` (next-note-by-string lookahead), `mat` (per-string material writes), `noteDraw` (single-note draw loop), `chordDraw` (chord draw loop). Reported every 5 seconds with p50 / p95 / max per segment and frame count, so before/after numbers on a target chart are reproducible (slopsmith#226). Off-by-default; the bench helpers (`pbBeg` / `pbEnd` / `pbReportTick`) are bound to a shared empty-function literal when the renderer instance is created (each `createHighway()` panel re-checks the flag), so the hot-path call sites are no-ops with negligible overhead (typically JIT-inlined).
+6
View File
@@ -0,0 +1,6 @@
/* Tailwind input for the 3D Highway plugin's own stylesheet.
Utilities only — core ships the single base reset (preflight), so this
plugin builds with corePlugins.preflight=false and must NOT re-include
`@tailwind base`. Generated artifact: assets/plugin.css (committed).
Regenerate with: bash build-tailwind.sh */
@tailwind utilities;
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Regenerate this plugin's own stylesheet (assets/plugin.css) from its content
# globs. Maintainer task — the generated CSS is committed, so end users / Docker
# / desktop builds never run this. Run it whenever you add Tailwind classes to
# screen.js / settings.html, and bump the plugin.json `version` so the injected
# <link>'s ?v= cache-buster fetches the fresh file.
#
# Pin the same Tailwind 3.x core uses so output stays diff-stable across
# rebuilds. Utilities only (corePlugins.preflight=false in tailwind.config.js) —
# core ships the one base reset.
set -euo pipefail
cd "$(dirname "$0")"
exec npx -y tailwindcss@3.4.19 \
-c tailwind.config.js \
-i _plugin.src.css \
-o assets/plugin.css \
--minify
+12
View File
@@ -0,0 +1,12 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.26.0",
"type": "visualization",
"bundled": true,
"script": "screen.js",
"styles": "assets/plugin.css",
"settings": { "html": "settings.html", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
"routes": "routes.py",
"tour": "tour.json"
}
+305
View File
@@ -0,0 +1,305 @@
"""Plugin-registered FastAPI routes for the 3dhighway visualization plugin.
Registered by slopsmith core via plugin.json's "routes" field — the
loader at plugins/__init__.py:589604 imports this module and calls
setup(app, context). context["config_dir"] points at the slopsmith
data directory; we namespace user uploads under
{config_dir}/plugin_uploads/highway_3d/.
This module owns the upload/serve/delete endpoints for the `video` bg
style (issue #19 follow-up). Single deterministic slot — each upload
replaces the previous file, no orphan accumulation. localStorage on
the renderer side stores only the filename, never the bytes.
"""
import asyncio
import os
import re
import tempfile
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse
from starlette.datastructures import UploadFile
PLUGIN_ID = "highway_3d"
ALLOWED_VIDEO_EXTS = {"mp4", "webm"}
ALLOWED_VIDEO_MIMES = {"video/mp4", "video/webm"}
MAX_VIDEO_BYTES = 50 * 1024 * 1024 # 50 MB raw
# Filenames the GET endpoint accepts. Tightened to the exact slot
# pattern this plugin produces — anything else (leftover upload-*.part
# temp files from a crashed upload, future schema additions, manual
# disk edits) gets a 404 rather than being served. The previous
# permissive regex would have happily streamed a `.part` file to a
# client that knew the name.
SLOT_FILENAME_RE = re.compile(r"^current\.(mp4|webm)$")
def setup(app: FastAPI, context: dict) -> None:
config_dir = Path(context["config_dir"])
upload_dir = config_dir / "plugin_uploads" / PLUGIN_ID
upload_dir.mkdir(parents=True, exist_ok=True)
# Serialises the atomic replace + other-ext cleanup so two concurrent
# uploads of different extensions (e.g. mp4 and webm) can't both finish
# streaming before either cleans up, leaving both files on disk. Streaming
# itself (the slow part) happens outside the lock; only the final
# replace + cleanup is held under it — so concurrent uploads of the *same*
# extension still overlap for all but the last microsecond.
_slot_lock = asyncio.Lock()
@app.post(f"/api/plugins/{PLUGIN_ID}/files")
async def upload_file(request: Request):
# Pre-parse Content-Length guard — fires before ANY body reading.
#
# FastAPI only reads request.form() when the handler/dependency
# explicitly asks for it. By accepting `Request` directly (rather
# than `file: UploadFile = File(...)`), we get headers without
# consuming the body. If Content-Length already indicates the
# upload is too large, we return 413 immediately — python-multipart
# never buffers a byte to disk.
#
# Clients that omit or forge Content-Length fall through to the
# streaming chunk-count cap in _do_upload, which remains as a
# defence-in-depth fallback.
cl = request.headers.get("content-length")
if cl is not None:
try:
cl_int = int(cl)
except ValueError:
raise HTTPException(400, "Invalid Content-Length header.")
if cl_int < 0:
raise HTTPException(400, "Invalid Content-Length header.")
if cl_int > MAX_VIDEO_BYTES:
raise HTTPException(
413,
f"Upload exceeds {MAX_VIDEO_BYTES // (1024 * 1024)} MB limit.",
)
# Body is only consumed here, after the Content-Length pre-check.
form = await request.form()
try:
file = form.get("file")
if not isinstance(file, UploadFile):
raise HTTPException(400, "Expected a file upload in field 'file'.")
try:
return await _do_upload(file)
finally:
try:
await file.close()
except Exception:
pass
finally:
# Release the form object (closes any remaining SpooledTemporaryFile
# references that weren't already closed by file.close() above).
try:
await form.close()
except Exception:
pass
async def _do_upload(file: UploadFile):
# Extension whitelist is the primary guard — file.filename
# (and thus the extension) is always present on a real upload,
# whereas content_type is unreliable: some OS / browser combos
# report it as empty or as the generic application/octet-stream
# for valid .mp4 / .webm files. This mirrors settings.html's
# client-side fallback so a working browser doesn't 400 here
# after passing the client check. Server-side raw decoding
# is left to the browser's <video> element on play.
ext = (Path(file.filename or "").suffix.lstrip(".") or "").lower()
if ext not in ALLOWED_VIDEO_EXTS:
raise HTTPException(400, "Filename must end in .mp4 or .webm.")
# MIME check applies only when the client supplied something
# specific. Empty / octet-stream / None mean "browser couldn't
# tell" — fall through to the extension whitelist that already
# passed above.
if (
file.content_type
and file.content_type != "application/octet-stream"
and file.content_type not in ALLOWED_VIDEO_MIMES
):
raise HTTPException(400, "Only MP4 and WebM are allowed.")
# Stream the body to a temp file so we never hold the full 50 MB
# in memory (and never doubled — the previous version buffered
# chunks AND a joined bytes object). Writes go through
# run_in_threadpool so the event loop isn't blocked by a
# multi-second disk write. Atomic os.replace at the end means
# a server crash mid-upload leaves the previous slot file
# intact rather than a half-written current.<ext>.
out_name = f"current.{ext}"
out_path = upload_dir / out_name
# mkstemp on the same filesystem as out_path is required for
# os.replace to be atomic. Suffix marks the partial so a stray
# leftover from a crashed upload is obvious on inspection.
fd, tmp_name = await run_in_threadpool(
tempfile.mkstemp, dir=str(upload_dir), prefix="upload-", suffix=".part"
)
tmp_path = Path(tmp_name)
bytes_read = 0
try:
# Wrap the raw fd in a Python file object so writes are
# guaranteed-complete: os.write can return a short write on
# some platforms / fd states and would silently truncate the
# upload. The buffered file object loops internally and
# raises on real errors.
#
# If fdopen itself fails, the fd hasn't been wrapped yet, so
# the outer try's tmpf.close path can't reach it — close
# manually here. The outer except still unlinks tmp_path.
try:
tmpf = await run_in_threadpool(os.fdopen, fd, "wb")
except BaseException:
try:
await run_in_threadpool(os.close, fd)
except OSError:
pass
raise
try:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
bytes_read += len(chunk)
if bytes_read > MAX_VIDEO_BYTES:
raise HTTPException(
413,
f"Video exceeds {MAX_VIDEO_BYTES // (1024 * 1024)} MB cap.",
)
await run_in_threadpool(tmpf.write, chunk)
finally:
# Close before any rename / unlink to avoid Windows
# file-locking surprises. Close also flushes the
# buffer so the bytes are on disk before os.replace.
await run_in_threadpool(tmpf.close)
# Reject empty uploads before the atomic rename. Without
# this guard, a misbehaving client (or a multipart body
# whose file part is empty) would replace the slot with a
# 0-byte file that the renderer then tries — and fails —
# to play. Existing slot stays untouched; the temp file
# is cleaned up by the outer except.
if bytes_read == 0:
raise HTTPException(400, "Empty upload — file is 0 bytes.")
# Hold the slot lock for the atomic replace + other-ext
# cleanup. Streaming (above) happens outside the lock so
# concurrent uploads of different extensions can overlap
# for most of their duration. Only the final commit is
# serialised. Under the lock there are no concurrent
# writers, so we can safely delete the opposite slot
# without a snapshot — whichever upload acquires the lock
# second simply supersedes the first, and the first
# upload's cleanup (which already ran) may have removed
# the second's now-absent file, or the second's cleanup
# removes the first's file now. Either way at most one
# slot file survives after the lock is released.
async with _slot_lock:
await run_in_threadpool(os.replace, str(tmp_path), str(out_path))
for e in ALLOWED_VIDEO_EXTS - {ext}:
try:
await run_in_threadpool((upload_dir / f"current.{e}").unlink)
except OSError:
# Another process holding the file (antivirus,
# in-flight GET) shouldn't 500 the upload. The
# new file is already in place; the stale one
# will get retried on the next upload or Clear.
pass
except BaseException:
# Any failure (size cap, write error, even cancellation):
# remove the temp file so we don't leak partial uploads on
# disk. unlink_missing_ok would be cleaner but isn't on
# older Python versions.
try:
await run_in_threadpool(tmp_path.unlink)
except OSError:
pass
raise
return {
"url": f"/api/plugins/{PLUGIN_ID}/files/{out_name}",
"name": out_name,
"size": bytes_read,
}
@app.get(f"/api/plugins/{PLUGIN_ID}/files/{{filename}}")
async def get_file(filename: str):
if not SLOT_FILENAME_RE.match(filename):
raise HTTPException(404, "Not found.")
path = upload_dir / filename
# Defense-in-depth: even with the regex above, resolve and
# confirm the resolved path stays inside upload_dir. Catches
# any future regex regression or symlink trickery.
try:
resolved = path.resolve()
resolved.relative_to(upload_dir.resolve())
except (OSError, ValueError):
raise HTTPException(404, "Not found.")
if not resolved.is_file():
raise HTTPException(404, "Not found.")
ext = resolved.suffix.lstrip(".").lower()
media = {"mp4": "video/mp4", "webm": "video/webm"}.get(
ext, "application/octet-stream"
)
# The slot URL is stable across re-uploads (we always overwrite
# current.<ext> in place), so without explicit cache headers a
# browser or upstream proxy will happily serve the previous
# video after a Replace operation. `no-cache` lets the cache
# store a copy but forces revalidation on every load — paired
# with the Last-Modified / ETag headers FileResponse adds, the
# browser sends If-Modified-Since and gets a 304 when unchanged.
return FileResponse(
resolved,
media_type=media,
headers={
"Cache-Control": "no-cache",
# nosniff prevents the browser from second-guessing the
# MIME we declared. The MIME is fixed to video/mp4 or
# video/webm by the slot pattern, but a malicious
# upload could try to sneak past via an allowed
# extension carrying e.g. HTML; nosniff keeps the
# browser from rendering it as anything other than a
# video stream.
"X-Content-Type-Options": "nosniff",
},
)
@app.delete(f"/api/plugins/{PLUGIN_ID}/files")
async def delete_files():
# Slot-level clear: removes every current.* in the upload dir
# regardless of extension. This is the only delete operation
# the client needs — the previous per-filename DELETE could
# leak the other extension's file (e.g. clearing current.mp4
# while current.webm survives) when client and server got out
# of sync about which slot was active.
#
# Best-effort + always 200: file-locking on Windows (an
# in-flight GET, antivirus, an OS file scanner) can transiently
# block unlink, and a 500 response would silently leave the
# client's localStorage in a "still has video" state because
# the UI doesn't update on a failed clear. The user's actual
# intent — "stop using this video" — is best served by
# returning success so the client clears its pointer and the
# next render uses the fallback style. Any leftover file
# comes back in `leftover` for visibility; operators or the
# next upload's pre-cleanup loop will handle it.
# Hold the slot lock so a concurrent upload's os.replace() can't
# sneak a new current.* into the slot between our glob and our
# unlink calls. Without the lock, a DELETE that interleaves with
# an upload could return "cleared" while the upload's replace
# commits a fresh file immediately after the unlink.
async with _slot_lock:
slot_paths = await run_in_threadpool(
lambda: list(upload_dir.glob("current.*"))
)
deleted = []
leftover = []
for path in slot_paths:
try:
await run_in_threadpool(path.unlink)
deleted.append(path.name)
except OSError:
leftover.append({"name": path.name, "error": "unlink failed"})
return JSONResponse({"ok": True, "deleted": deleted, "leftover": leftover})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
/**
* Tailwind build config for the 3D Highway plugin's OWN stylesheet.
*
* Slopsmith serves Tailwind as a prebuilt stylesheet and core only scans core
* source at build time (constitution Principle II — no Play CDN / runtime JIT).
* This plugin owns its utilities so it styles correctly even when core's build
* didn't scan it (it's excluded from core's content globs). It uses arbitrary
* values (`text-[10px]`, `max-w-[12rem]`) that no "complete" Tailwind set
* contains, so a self-built, content-scanned sheet is mandatory.
*
* Regenerate assets/plugin.css with: bash build-tailwind.sh
*/
module.exports = {
// Core ships the single base reset; this plugin emits utilities only so it
// doesn't double the preflight and fight core's styles.
corePlugins: { preflight: false },
content: [
// List only the files that carry Tailwind classes — screen.js (renderer
// + HUD markup) and settings.html. A broad ./*.{js,html} would also scan
// THIS config (its comments mention class-like strings such as
// text-[10px]) and emit them spuriously; tour.json is plain text.
'./screen.js',
'./settings.html',
],
theme: {
extend: {
// Mirror core's theme tokens so classes like `bg-dark-700` compile
// inside this standalone build.
colors: {
dark: { 900: '#050508', 800: '#0a0a12', 700: '#10101e', 600: '#181830', 500: '#1e1e3a' },
accent: { DEFAULT: '#4080e0', light: '#60a0ff', dark: '#2060b0' },
gold: '#e8c040',
},
fontFamily: {
display: ['"Inter"', 'system-ui', 'sans-serif'],
},
},
},
// Belt-and-suspenders for any dark/accent class built indirectly (none are
// today — all usage is literal — but this keeps the sheet self-sufficient).
safelist: [
{ pattern: /^(bg|text|border)-(dark|accent)(-.+)?$/ },
],
plugins: [],
};
+44
View File
@@ -0,0 +1,44 @@
{
"version": 1,
"tour": [
{
"id": "welcome",
"title": "Welcome to the 3D Highway",
"content": "This plugin renders your song's note chart as a live 3D highway. Let's take a quick look at what you can do.",
"shape": "bubble",
"position": "auto"
},
{
"id": "canvas",
"selector": ".h3d-wrap[data-h3d-primary]",
"waitFor": ".h3d-wrap[data-h3d-primary]",
"title": "The Note Highway",
"content": "Notes flow toward you in 3D. The glowing horizontal line marks the strum point — hit notes as they cross it.",
"shape": "spotlight",
"position": "bottom"
},
{
"id": "camera",
"selector": "#player",
"title": "Auto-Tracking Camera",
"content": "The camera automatically follows the action, smoothly panning to keep the active fret range in frame. Adjust camera height, distance, and smoothing in Settings → 3D Highway.",
"shape": "bubble",
"position": "top"
},
{
"id": "customise",
"selector": "#player",
"title": "Customise the Look",
"content": "Open Settings → 3D Highway to change colour palettes, background effects, glow intensity, and camera smoothing.",
"shape": "bubble",
"position": "left"
},
{
"id": "outro",
"title": "You're ready to shred",
"content": "That's the 3D Highway. Load a song and hit Play to see it in action. Restart this tour any time with the ? button.",
"shape": "bubble",
"position": "auto"
}
]
}