diff --git a/README.md b/README.md index 657935db..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) | --- @@ -309,7 +311,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:" 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/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 ddacfbae..38116f4d 100644 --- a/static/index.html +++ b/static/index.html @@ -175,12 +175,11 @@ Settings - -

Supporters

+

We Recommend

Wonderful people doing beautiful work. Go meet them ❤️

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"; 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: