From 958634a0433db465f649be969a5ab302d1c3195a Mon Sep 17 00:00:00 2001 From: Thales <> Date: Fri, 26 Jun 2026 09:47:43 +0100 Subject: [PATCH 1/3] chore: drop "karaoke" wording from the MP4 export The video export is just an MP4 export, not specifically a karaoke feature. Replace all "karaoke" references in UI strings, the download filename, comments, docstrings, and docs with neutral MP4/video wording. No behavior change. - UI: MP4 "Export Mix" subtitle -> "Export mix with the original video". - Download filename: _karaoke.mp4 -> <title>_video.mp4 (frontend download attr and backend Content-Disposition). - Comments / docstrings / README updated; no renamed identifiers (downloadCurrentVideo, /video.mp4, has_video were already neutral). --- README.md | 2 +- app/api/stems.py | 6 +++--- app/core/config.py | 2 +- app/core/models.py | 2 +- app/pipeline/download.py | 8 ++++---- app/pipeline/runner.py | 4 ++-- static/js/main.js | 6 +++--- static/js/player.js | 4 ++-- tests/test_stems_api.py | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 657935db..fd831d14 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,7 @@ Stems land in `./jobs/` on the host. Demucs weights are cached in a named volume | PATCH | `/api/jobs/{id}/sections` | Save waveform section markers for a job | | GET | `/api/jobs/{id}/stems/{name}.wav` | Stream a single stem WAV file | | GET | `/api/jobs/{id}/stems/{name}.mp3` | Transcode and stream a stem as MP3 | -| GET | `/api/jobs/{id}/video.mp4` | Mux the current mix with the source video (MP4 upload or YouTube) into a karaoke MP4 | +| GET | `/api/jobs/{id}/video.mp4` | Mux the current mix with the source video (MP4 upload or YouTube) into an MP4 | | DELETE | `/api/jobs/{id}` | Remove job dir from disk (terminal jobs only) | --- diff --git a/app/api/stems.py b/app/api/stems.py index c45d3f73..5447ddb3 100644 --- a/app/api/stems.py +++ b/app/api/stems.py @@ -61,7 +61,7 @@ def _validate_stem_path(job_id: str, name: str): def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]: """Parse and validate parallel comma-separated lane names and linear gains. - Shared by the audio mixdown and the karaoke-video mux. Raises HTTPException + Shared by the audio mixdown and the MP4 video mux. Raises HTTPException on malformed input, unknown lanes, or out-of-range gains.""" names = [s for s in stems.split(",") if s] raw_gains = [g for g in gains.split(",") if g] @@ -270,7 +270,7 @@ async def get_video_mixdown( gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"), ) -> StreamingResponse: """Mux a fresh audio mixdown of the current mixer state with the job's preserved - video into a karaoke MP4 (issue #219). Mirrors get_mixdown's audio graph (encoded + video into an MP4 (issue #219). Mirrors get_mixdown's audio graph (encoded as AAC) and stream-copies video.mp4 -- the silent video kept from an .mp4 upload or the real video stream downloaded for a YouTube job. 404 when the job has no video (SoundCloud / plain audio uploads). @@ -328,7 +328,7 @@ async def get_video_mixdown( "pipe:1", ] - filename = f"{_safe_title(job.title)}_karaoke.mp4" + filename = f"{_safe_title(job.title)}_video.mp4" return StreamingResponse( _stream_ffmpeg(cmd), media_type="video/mp4", diff --git a/app/core/config.py b/app/core/config.py index a685ec4e..7045309f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -74,7 +74,7 @@ def _detect_device() -> str: TIMEOUT_FFMPEG = _env_int("STEMDECK_TIMEOUT_FFMPEG", 300) TIMEOUT_ANALYZE = _env_int("STEMDECK_TIMEOUT_ANALYZE", 120) TIMEOUT_DEMUCS_STALL = _env_int("STEMDECK_TIMEOUT_DEMUCS_STALL", 1800) -# Max height for the karaoke-MP4 video stream pulled from YouTube (issue #219). +# Max height for the MP4 video stream pulled from YouTube (issue #219). # Capped to keep downloads reasonable; 1080p of a full song is large. VIDEO_MAX_HEIGHT = max(144, _env_int("STEMDECK_VIDEO_MAX_HEIGHT", 720)) diff --git a/app/core/models.py b/app/core/models.py index b2a76fe3..26e4dc26 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -53,7 +53,7 @@ class Job: mix_url: str | None = None # populated when a strict subset was selected source_url: str | None = None # original URL or "local:<filename>" for file uploads # True when a silent video track (video.mp4) was preserved from an .mp4 - # upload, enabling the "Export Mix (with video)" karaoke export. + # upload, enabling the "Export Mix (with video)" MP4 export. has_video: bool = False error: str | None = None # Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages. diff --git a/app/pipeline/download.py b/app/pipeline/download.py index 4224a056..4dac6f30 100644 --- a/app/pipeline/download.py +++ b/app/pipeline/download.py @@ -150,7 +150,7 @@ def normalize_youtube_url(url: str) -> str: def _download_video_track(job: Job, url: str, job_dir: Path) -> None: """Best-effort: download a video-only H.264/MP4 stream to video.mp4 for the - karaoke-MP4 export (issue #219). The audio source is downloaded separately as + MP4 export (issue #219). The audio source is downloaded separately as usual; this is a second, additive fetch so the audio pipeline is untouched. Video-only MP4 needs no ffmpeg merge, so this can't break an audio-only job: @@ -159,7 +159,7 @@ def _download_video_track(job: Job, url: str, job_dir: Path) -> None: JobCancelled, which the runner treats like any other cancellation. Capped at VIDEO_MAX_HEIGHT to keep downloads reasonable -- a full song at - 1080p is large, and karaoke playback doesn't need it.""" + 1080p is large, and the MP4 export doesn't need it.""" def vhook(d: dict) -> None: if job.cancel_requested: @@ -240,7 +240,7 @@ def hook(d: dict) -> None: _set(job, progress=1.0, stage="Download complete") # YouTube jobs additionally fetch the real video stream (below) for the - # karaoke-MP4 export (issue #219). SoundCloud is audio-only and excluded. + # MP4 export (issue #219). SoundCloud is audio-only and excluded. is_youtube = url.startswith("https://www.youtube.com/") # No postprocessors -- Demucs reads the raw audio container (webm/m4a/opus/...) @@ -295,7 +295,7 @@ def hook(d: dict) -> None: deduped = [t for t in raw_tags if not (t in seen or seen.add(t))] # type: ignore[func-returns-value] _set(job, tags=deduped[:8] or None) - # Best-effort: fetch the real video stream for the karaoke-MP4 export. + # Best-effort: fetch the real video stream for the MP4 export. # Non-fatal -- on any failure the job proceeds audio-only. if is_youtube: _download_video_track(job, url, job_dir) diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py index 240a2249..032eee77 100644 --- a/app/pipeline/runner.py +++ b/app/pipeline/runner.py @@ -45,7 +45,7 @@ def _check_cancel(job: Job) -> None: def _extract_video_track(job: Job, source: Path, job_dir: Path) -> None: """For an .mp4 upload, preserve a silent video-only track at video.mp4 so the studio can later mux it with a custom stem mix - into a karaoke video (issue #219). Stream-copies the video (no + into an MP4 (issue #219). Stream-copies the video (no re-encode) -- fast and lossless. Best-effort: an .mp4 with no video stream (audio-only container) @@ -83,7 +83,7 @@ def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path: would otherwise process silently and output as silence. For .mp4 uploads, first preserves a silent video.mp4 for later - karaoke-video export. Deletes the original source file after a + MP4 export. Deletes the original source file after a successful transcode.""" from app.core.config import ffmpeg_executable diff --git a/static/js/main.js b/static/js/main.js index bafa4fd5..9c1e3edc 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -176,12 +176,12 @@ function wireFooterControls() { // MP4 exports the mix muxed with the source video. Stems and region have no // video equivalent, so they're hidden (via .fmt-mp4) while MP4 is selected, - // leaving just "Export Mix" relabelled for karaoke. + // leaving just "Export Mix". function applyFormatState() { const video = format === "mp4"; exportPanel?.classList.toggle("fmt-mp4", video); if (mixDescEl) { - mixDescEl.textContent = video ? "Export mix with video for karaoke" : "Export the mixed audio"; + mixDescEl.textContent = video ? "Export mix with the original video" : "Export the mixed audio"; } if (!video) updateLoopRegionVisual(); // restores the region item's disabled state } @@ -211,7 +211,7 @@ function wireFooterControls() { panelOpen() ? closePanel() : openPanel(); }); - // Export Mix: MP4 produces the karaoke video; any other format an audio mix. + // Export Mix: MP4 produces the video; any other format an audio mix. itemMix?.addEventListener("click", (e) => { e.stopPropagation(); if (busy) return; diff --git a/static/js/player.js b/static/js/player.js index 2af4e567..9708e9b6 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -1335,7 +1335,7 @@ export function downloadCurrentMix(ext = "wav") { return true; } -// Karaoke video: the preserved source video muxed with the current audio mix. +// MP4 export: the preserved source video muxed with the current audio mix. // Only meaningful for mp4-sourced jobs (currentJobHasVideo()); returns false when // there's no video track or every lane is muted. export function downloadCurrentVideo() { @@ -1351,7 +1351,7 @@ export function downloadCurrentVideo() { .replace(/_{2,}/g, "_") .slice(0, 80) .replace(/^_+|_+$/g, ""); - const name = safe ? `${safe}_karaoke.mp4` : "karaoke.mp4"; + const name = safe ? `${safe}_video.mp4` : "video.mp4"; _triggerDownload(`/api/jobs/${currentJobId}/video.mp4?${q}`, name); return true; } diff --git a/tests/test_stems_api.py b/tests/test_stems_api.py index ae68c746..fca18104 100644 --- a/tests/test_stems_api.py +++ b/tests/test_stems_api.py @@ -386,7 +386,7 @@ def test_all_stems_zip_flac(client, tmp_path): assert zf.read("vocals.flac")[:4] == b"fLaC" -# --- karaoke video mux endpoint (#219) --- +# --- MP4 video mux endpoint (#219) --- def _make_video_file(tmp_path, job_id: str) -> None: From 51ef53dae4eb66da3178f3f1974e698c27408c74 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Fri, 26 Jun 2026 10:17:13 +0100 Subject: [PATCH 2/3] chore: rename "Supporters" UI label to "We Recommend" Match the README "We Recommend" section. "Supporters" implied a sponsorship relationship the project explicitly does not have (no money or funding accepted); these are editorial recommendations of makers and artists. Updates the rail button (title/aria-label/chip) and the dialog heading. Internal ids stay friendsBtn/friendsTitle. --- static/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/static/index.html b/static/index.html index ddacfbae..03a2db34 100644 --- a/static/index.html +++ b/static/index.html @@ -175,12 +175,12 @@ </svg> <span>Settings</span> </button> - <button class="rail-btn" id="friendsBtn" type="button" title="Supporters" aria-label="Supporters" aria-haspopup="dialog"> + <button class="rail-btn" id="friendsBtn" type="button" title="We Recommend" aria-label="We Recommend" aria-haspopup="dialog"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"> <rect x="2" y="7" width="20" height="13" rx="2"/> <path d="m17 2-5 5-5-5"/> </svg> - <span>Supporters</span> + <span>We Recommend</span> </button> <button class="rail-btn" id="aboutBtn" type="button" title="Help" aria-label="Help"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"> @@ -717,7 +717,7 @@ <h2 id="aboutTitle">StemDeck</h2> <path d="m17 2-5 5-5-5"/> </svg> </div> - <h2 id="friendsTitle">Supporters</h2> + <h2 id="friendsTitle">We Recommend</h2> <p class="about-tagline">Wonderful people doing beautiful work. Go meet them ❤️</p> <div class="lib-friends-grid" id="friendsDialogGrid"></div> </div> From ead4775eaac8bafc4afe317de17cce96270778a3 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Fri, 26 Jun 2026 12:12:20 +0100 Subject: [PATCH 3/3] feat: polish "We Recommend" and add Thomann + Analog4Lyfe - Stack the rail label onto two centered lines ("We" / "Recommend") so it no longer clips the 40px chip, and swap the TV icon for a heart (both the rail button and the dialog header). - Add a monogram avatar fallback: tiles with no image (or a broken image) render an on-brand circular initial instead of a broken-image icon. - Add two recommendations: Thomann (@thomann.music) and Analog4Lyfe (@analog4lyfe), in the dialog grid and the README table. Their images (static/img/friends/{thomann,analog4lyfe}.jpg) can be dropped in later; until then they show the monogram fallback. --- README.md | 2 ++ static/css/daw.css | 20 ++++++++++++++++++++ static/index.html | 10 ++++------ static/js/catalog.js | 26 ++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fd831d14..3004b144 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ StemDeck is free and **does not accept any money, sponsorship, or funding** - no | Lisbon Guitar Works | Guitar building | [dlimaguitars.com](https://dlimaguitars.com) | | Joao Gaspar | Producer/Film Scorer, Touring/Session Musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | | Kris Luthier | Luthier and Musical Instrument Repair, Lisboa | [@krisluthier](https://www.instagram.com/krisluthier) | +| Thomann | Musical instruments & music gear | [@thomann.music](https://www.instagram.com/thomann.music) | +| Analog4Lyfe | Analog music gear | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | --- diff --git a/static/css/daw.css b/static/css/daw.css index e6998f43..57f43d79 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -430,6 +430,10 @@ input, textarea { font-family: inherit; } .rail-btn.active { color: var(--accent); background: var(--panel); } .rail-btn svg { flex-shrink: 0; } .rail-btn span { white-space: nowrap; } +/* "We Recommend" is wider than the other one-word rail labels, so it stacks onto + two centered lines; let the button grow so the second line + icon aren't clipped. */ +.rail-btn.rail-recommend { height: auto; min-height: 40px; padding: 3px 0; } +.rail-btn.rail-recommend span { white-space: normal; text-align: center; line-height: 1.1; } /* Sidebar body */ .sidebar-body { @@ -2218,6 +2222,22 @@ input, textarea { font-family: inherit; } object-fit: cover; border: 1px solid var(--border); } +/* Monogram fallback when a tile has no image (or it fails to load): matches the + round avatar size, in the accent colour, so the grid stays on-brand. */ +.lib-friend-monogram { + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-sans); + font-size: 20px; + font-weight: 600; + color: var(--accent); + background: var(--panel-3); + border: 1px solid var(--border); +} /* Small Instagram glyph under the text on tiles that link to Instagram */ .lib-friend-ig { /* block + no-shrink avoids the WebKit baseline clip on small inline SVGs */ diff --git a/static/index.html b/static/index.html index 03a2db34..38116f4d 100644 --- a/static/index.html +++ b/static/index.html @@ -175,12 +175,11 @@ </svg> <span>Settings</span> </button> - <button class="rail-btn" id="friendsBtn" type="button" title="We Recommend" aria-label="We Recommend" aria-haspopup="dialog"> + <button class="rail-btn rail-recommend" id="friendsBtn" type="button" title="We Recommend" aria-label="We Recommend" aria-haspopup="dialog"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"> - <rect x="2" y="7" width="20" height="13" rx="2"/> - <path d="m17 2-5 5-5-5"/> + <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/> </svg> - <span>We Recommend</span> + <span>We<br>Recommend</span> </button> <button class="rail-btn" id="aboutBtn" type="button" title="Help" aria-label="Help"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"> @@ -713,8 +712,7 @@ <h2 id="aboutTitle">StemDeck</h2> </button> <div class="about-logo" aria-hidden="true"> <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> - <rect x="2" y="7" width="20" height="13" rx="2"/> - <path d="m17 2-5 5-5-5"/> + <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/> </svg> </div> <h2 id="friendsTitle">We Recommend</h2> diff --git a/static/js/catalog.js b/static/js/catalog.js index cb51b291..f5692289 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -40,6 +40,20 @@ const FRIENDS = [ logo: "/img/friends/kris-luthier.jpg", avatar: true, }, + { + name: "Thomann", + role: "Musical instruments & music gear", + url: "https://www.instagram.com/thomann.music", + logo: "/img/friends/thomann.jpg", + avatar: true, + }, + { + name: "Analog4Lyfe", + role: "Analog music gear", + url: "https://www.instagram.com/analog4lyfe", + logo: "/img/friends/analog4lyfe.jpg", + avatar: true, + }, ]; // Instagram glyph (Simple Icons), shown under tiles that link to Instagram. @@ -1632,13 +1646,25 @@ function wireSupportersDialog() { a.rel = "noopener noreferrer"; a.title = f.name; a.style.setProperty("--tilt", tilts[i % tilts.length]); + // A monogram avatar (first initial) keeps the tile on-brand when an entry + // has no image, or its image fails to load (e.g. before the asset is added). + const makeMonogram = () => { + const m = document.createElement("span"); + m.className = "lib-friend-monogram"; + m.textContent = (f.name || "?").trim().charAt(0).toUpperCase(); + m.setAttribute("aria-hidden", "true"); + return m; + }; if (f.logo) { const img = document.createElement("img"); img.className = f.avatar ? "lib-friend-avatar" : "lib-friend-logo"; img.src = f.logo; img.alt = f.name; img.loading = "lazy"; + img.addEventListener("error", () => img.replaceWith(makeMonogram())); a.appendChild(img); + } else { + a.appendChild(makeMonogram()); } const name = document.createElement("span"); name.className = "lib-friend-name";