Merge main into feat/highway-3d-fret-wire-hit-flash

Bring the branch up to date with main (includes #994, which also touched
highway_3d/screen.js — the lane hit-line fix — in a different region).
This commit is contained in:
Kris Anderson
2026-07-16 19:40:23 -04:00
22 changed files with 1262 additions and 61 deletions
+90 -17
View File
@@ -46,6 +46,8 @@ from pathlib import Path
from fastapi import Body, HTTPException
from fastapi.responses import FileResponse
import sloppak
from dlc_paths import _resolve_dlc_path
from progression import instrument_for_arrangement
PLUGIN_ID = "career"
@@ -53,6 +55,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
# arbitrary caller can ask for.
MAX_GIG_SONGS = 32
_lock = threading.Lock()
_state = {
@@ -517,27 +522,39 @@ def _current_venue():
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
def _fill_genre_songs(gkey, exclude, limit):
"""Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked.
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
That restriction created a hole: a song you'd played on a DIFFERENT
instrument's arrangement has a stats row, so it was excluded here — and it
lives in the played bucket for THAT instrument, not this passport's, so it
was excluded there too. It could never be gigged. A player with 137 metalcore
songs, all played on another instrument, got a 404 (reproduced). The player's
library is the pool; whether a song has stats on some other instrument has no
bearing on whether it can be in THIS gig.
Shuffled, so re-roll actually changes the set. The old version returned the
library's first N in table order every time, so re-roll was a no-op for any
set drawn from the filler (reproduced).
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
songs, single-user); push into SQL if propose ever feels slow.
"""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
pool = [
{"filename": filename, "title": title or filename, "artist": artist or ""}
for filename, title, artist, genre in rows
if _genre_key(genre) == gkey and filename not in exclude
]
random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit]
def _validate_pack_dir(pack_dir: Path):
@@ -734,6 +751,62 @@ def setup(app, context):
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
def prepare_gig(body: dict = Body(...)):
"""Unpack every song of the set BEFORE the gig starts.
A feedpak is a zip: the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
finished a number and then sat waiting for the next one to unpack, mid-
gig. A set is a known list up front, so extract it all while the player
is still looking at the poster.
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
already-unpacked dir without rewriting it. Best-effort per song — one
bad feedpak must not block the set from starting (the play itself will
surface the error, exactly as it does outside a gig).
"""
raw = (body or {}).get("songs")
# A str is iterable: without the list check, "abc" would prepare three
# one-character "songs". Cap the count too — this endpoint unpacks zips,
# so an oversized list is real work, and a setlist is a handful of songs.
if not isinstance(raw, list):
return {"ok": True, "prepared": 0, "failed": []}
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
if not files:
return {"ok": True, "prepared": 0, "failed": []}
# .get, not []: a host that doesn't hand us the resolvers (or has no
# library configured) must degrade to "extract lazily, as before" — this
# is an optimisation, and it is never allowed to be the thing that stops
# a gig from starting.
get_dlc = context.get("get_dlc_dir")
get_cache = context.get("get_sloppak_cache_dir")
dlc_root = get_dlc() if callable(get_dlc) else None
cache_root = get_cache() if callable(get_cache) else None
if dlc_root is None or cache_root is None:
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
root = Path(dlc_root)
prepared, failed = 0, []
for fn in files:
# CONTAINMENT FIRST. resolve_source_dir() does a bare
# `dlc_root / filename` with no guard, so a crafted `../..` would
# walk straight out of the library. Every other filename-bound
# handler validates through _resolve_dlc_path; so does this one.
safe = _resolve_dlc_path(root, fn)
if safe is None:
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
failed.append(fn)
continue
try:
sloppak.resolve_source_dir(fn, root, Path(cache_root))
prepared += 1
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
failed.append(fn)
return {"ok": True, "prepared": prepared, "failed": failed}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
@@ -775,7 +848,7 @@ def setup(app, context):
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
+62 -2
View File
@@ -12,6 +12,10 @@
'use strict';
const API = '/api/plugins/career';
// Unpacking a setlist is real work (zips, possibly on a slow/network drive),
// so this is generous — but it is a CEILING, not a wait. Past it we start the
// gig and let the first play extract lazily, as it always did.
const PREPARE_TIMEOUT_MS = 60000;
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
const NO_VENUE = '__none__';
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
@@ -1123,10 +1127,52 @@
sfx('page');
}
function startGig() {
// Unpack the whole set before the first note.
//
// A feedpak is a zip, and the first play of one pays for its extraction. In
// a set that cost landed BETWEEN songs: the player finished a number and
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
// known up front, so warm it all while the poster is still on screen.
//
// Best-effort by design: a library that won't pre-extract must not stop the
// gig from starting — the play itself surfaces the error the same way it
// does outside a gig. Slow is better than blocked.
async function prepareGigSongs(prop, btn) {
const label = btn && btn.textContent;
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
// A bare `await fetch(...)` only rejects on a network ERROR — a server
// that accepts the connection and then never answers hangs forever, and
// the gig would never start. That would make this optimisation the very
// thing it promises never to be: the reason you cannot play. Give up
// waiting and let the first play extract lazily, exactly as before.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
try {
await fetch(`${API}/gigs/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
signal: ctrl.signal,
});
} catch (_) {
// abort, offline, non-2xx — all the same: start the gig anyway.
} finally {
clearTimeout(timer);
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
}
}
async function startGig(btn) {
const prop = _ppGigProposal;
const q = window.feedBack && window.feedBack.playQueue;
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
// Extract the setlist BEFORE the stage is borrowed and the queue starts,
// so a failure here leaves nothing half-applied to unwind.
await prepareGigSongs(prop, btn);
// The poster's Play could have been cancelled while we were unpacking.
if (_ppGigProposal !== prop) return;
// The gig BORROWS the stage: stash whatever venue/viz the user had so
// the set ending gives it back (unlike "Play here", which is an
// explicit persistent choice on the venue card).
@@ -1146,7 +1192,21 @@
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* viz optional — restore stays intact */ }
}
// Push the gig's venue pack to the crowd layer NOW.
//
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
// and pushCrowdManifest is called only from refresh() — the career
// tab's own reload. A gig navigates AWAY from the career tab to the
// player, so refresh() never runs during it, and setting the override
// above does nothing on its own. The result the testers saw: the venue
// visualization turns on (3D highway) but its crowd/stage pack never
// loads, so the song plays over the bare highway backdrop ("standard
// particles"), or over whatever venue a previous refresh() happened to
// leave applied. We just changed the override to this gig's venue, so
// re-push for it. _state is the career state the booking screen already
// fetched; guard for the rare null.
_appliedManifestVenue = null;
if (_state) pushCrowdManifest(_state);
_ppGigRun = {
songs: prop.songs,
venue_id: prop.venue_id,
@@ -1424,7 +1484,7 @@
}
const gigBtn = e.target.closest('[data-pp-gig]');
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
if (e.target.closest('[data-pp-gig-reroll]')) {
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
return;
+45 -13
View File
@@ -12726,8 +12726,19 @@
const tC = now + (dt0 + dt1) * 0.5 - BEHIND;
const b = laneBoundsFromAnchor(getChartAnchorAt(anchors, tC));
if (!b) continue;
const z0 = dZ(dt0) + TS * BEHIND;
const z1 = dZ(dt1) + TS * BEHIND;
// The lane STOPS AT THE HIT LINE (z = 0) — issue #991. The
// slice window starts BEHIND seconds in the past, so the
// first slices map to positive z, i.e. past the hit line
// toward the player. Nothing is ever drawn there: notes and
// chord frames clamp to Math.min(0, dZ(dt)), so that strip
// is lane with nothing on it. Clamp the NEAR edge only —
// the far edge stays at dZ(AHEAD+BEHIND)+TS*BEHIND = -AHEAD*TS,
// aligned with the note horizon, exactly as before.
const z0 = Math.min(0, dZ(dt0) + TS * BEHIND);
const z1 = Math.min(0, dZ(dt1) + TS * BEHIND);
// Slice lies entirely past the hit line -> zero length, nothing
// to draw. Skip before the arp probe so it costs nothing.
if (z0 === z1) continue;
const arpSlice = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
? arpeggioLaneOuterRailLaneSlice(
dt0, dt1, now,
@@ -12876,9 +12887,13 @@
divMin = dMin;
divMax = dMax;
// Same fix: extend to AHEAD+BEHIND so far edge = -AHEAD*TS.
const laneLen = TS * (AHEAD + BEHIND);
const zLane = -laneLen / 2 + TS * BEHIND;
// Far edge at -AHEAD*TS (the note horizon), near edge at the
// hit line (z = 0) — the lane does not run past it toward the
// player, where nothing is ever drawn (#991). Spanning
// AHEAD+BEHIND and shifting by +TS*BEHIND put the near edge at
// +TS*BEHIND; spanning AHEAD alone keeps the same far edge.
const laneLen = TS * AHEAD;
const zLane = -laneLen / 2;
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
mLaneOdd.opacity = laneOp;
@@ -12898,7 +12913,8 @@
}
if (highwayIntensity > 0.05) {
const divLen = TS * (AHEAD + BEHIND);
// Matches the lane above: ends at the hit line (#991).
const divLen = TS * AHEAD;
const yPos = boardY + 0.03 * K;
const divOp2 = 0.02 + highwayIntensity * 0.1;
const divOpArp2 = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
@@ -12911,7 +12927,7 @@
for (let f = fDivA; f <= fDivB; f++) {
if (hwyLaneArpOuterDividers && (f === fDivA || f === fDivB)) continue;
const div = pLaneDivider.get();
div.position.set(xFret(f), yPos, dZ(0) - divLen * 0.5 + TS * BEHIND);
div.position.set(xFret(f), yPos, -divLen * 0.5);
div.material = mLaneDivider;
div.scale.set(1, 1, divLen);
div.renderOrder = 2;
@@ -12930,8 +12946,10 @@
// ── Fret boundary extension lines ─────────────────────────
if (mLaneDividerExt && fretDividersVisible) {
const extLaneLen = TS * (AHEAD + BEHIND);
const extZMid = -extLaneLen / 2 + TS * BEHIND;
// Same hit-line stop as the lane (#991) — otherwise these lines
// would be the only floor geometry still running past it.
const extLaneLen = TS * AHEAD;
const extZMid = -extLaneLen / 2;
const extYPos = boardY + 0.03 * K;
mLaneDividerExt.opacity = Math.max(0.3, 0.3 + highwayIntensity * 0.15);
for (let f = 0; f <= NFRETS; f++) {
@@ -15568,15 +15586,29 @@
// highway throttled the whole room. Pausing the song dropped the
// venue, the crowd and the stage to 10 fps.
//
// Only claim continuous frames while a crowd video is actually
// rolling: with no venue pack (the common case) the paused scene IS
// static and the throttle should still save the GPU.
// Two independent sources of motion, and BOTH must keep their frames:
//
// • a crowd video rolling on its own clock (career venue pack), and
// • the venue scene's own fake-depth motion — the backdrop breathes,
// the haze drifts, warmth pulses, the shimmer moves. That is
// Math.sin(t) in the draw loop (see _venueApplyFakeDepthMotion),
// so it only moves while we are actually given frames, and it runs
// with NO pack at all.
//
// The throttle fires whenever the CHART CLOCK is stalled — which is
// not just a pause. A count-in and the credits/author overlay stall it
// exactly the same way, so the venue was stuttering there too.
//
// With no venue at all (plain 3D highway) the paused scene really is a
// still picture: motion mode reads 'off', we claim nothing, and the
// throttle still saves the GPU as #654 intended.
needsContinuousFrames() {
if (!_isReady || _ctxLost) return false;
for (const v of _venueCrowdVideos) {
if (v && !v.paused && !v.ended && v.readyState >= 2) return true;
}
return false;
// 'off' also covers prefers-reduced-motion and "no venue scene".
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
},
draw(bundle) {