mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Clean release snapshot
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
Executable
+17
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:589–604 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
@@ -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: [],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user