Files
esh-pfi-infrastructure/stacks/scriberr/compose.yaml
T
vh ec888c3130 fix(scriberr): model caches on /tank; HF_TOKEN is a placeholder (no real credential)
HF_HOME, TORCH_HOME and NLTK_DATA now point under /app/whisperx-env (the
/tank bind mount). Before, ~6 GB of models sat in the container layer and
every recreate threw them away. The cache was copied across (98/2/78 files,
apparent sizes identical).

HF_TOKEN is now a placeholder. Measured: with an invalid token, cached gated
pyannote models still load (huggingface_hub falls back to the cache on the 401),
and public models still download (HTTP 200). Scriberr only checks that the token
is non-empty. Re-verified on GPU 3: pyannote community-1 and WhisperX+3.1
diarization both exit 0 from the /tank cache with no downloads.
2026-10-06 13:33:17 -07:00

157 lines
9.6 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Scriberr — self-hosted audio/video transcription with speaker diarization.
# Upstream: https://github.com/rishikanthc/Scriberr (Go + SvelteKit, SQLite).
#
# Transcription runs locally via WhisperX (Whisper + pyannote diarization);
# NVIDIA Parakeet / Canary models are also selectable in the UI. Optional
# summarisation / transcript chat talks to any OpenAI-compatible endpoint —
# point it at the LiteLLM gateway rather than a paid API (see README).
#
# ── IMAGE: BUILT LOCALLY, ON PURPOSE ──────────────────────────────────────
# fv-ml1's RTX PRO 6000 Blackwell cards are **sm_120**. Upstream publishes
# `scriberr-cuda` (built for sm_61…sm_89 — no sm_120 kernels) and documents a
# `scriberr-cuda-blackwell` image that **has never actually been published**
# (GHCR returns no tags for it, checked 2026-08-23). The sm_120 path upstream
# ships is `Dockerfile.cuda.12.9` (CUDA 12.9.1 + cu128 torch), built locally.
# Do NOT "simplify" this to the published `scriberr-cuda` image — it will
# fail on these cards or silently fall back to CPU.
# Rebuild: see README "Rebuilding" — checkout lives at
# /tank/scriberr/src/Scriberr on fv-ml1.
#
# ── GPU PINNING ───────────────────────────────────────────────────────────
# Pinned to **GPU 3** (Prime, 2026-09-30 1322) via explicit device_ids. Scriberr holds 0 VRAM
# when idle, so it is an ON-DEMAND tenant of GPU 3's full-size-seat reserve, like Blender: when a
# full-size seat (Flash-Next) claims GPU 3, Scriberr STEPS ASIDE. Its planned landing spot then is
# irv-ml1's A6000, not GPU 1 (GPU 1's headroom now funds intern-decision's 32k-token calls).
# (It was on GPU 1 until 2026-09-30, beside the vLLM seats; history in stacks/scriberr/README.md.)
# NOTE: do NOT add `NVIDIA_VISIBLE_DEVICES=all` (as upstream's compose does).
# It overrides the device_ids reservation and exposes every card.
#
# All tunables live in .env — edit that, not this file.
services:
scriberr:
image: ${SCRIBERR_IMAGE:-scriberr:local-blackwell}
container_name: scriberr
restart: unless-stopped
ports:
- "${SCRIBERR_BIND:-0.0.0.0}:${SCRIBERR_PORT}:8080"
volumes:
# Bind mounts rather than named volumes: /var/lib/docker on fv-ml1
# lives on zroot with limited headroom, while /tank has terabytes.
# Model weights (Whisper, pyannote, NeMo) land in whisperx-env and are
# multi-GB — they must not go anywhere near the root pool.
- ${SCRIBERR_DATA_DIR}:/app/data
- ${SCRIBERR_ENV_DIR}:/app/whisperx-env
environment:
# ⚠ 10001, NOT the fleet-usual 1000 — this is load-bearing.
# Dockerfile.cuda.12.9 creates `appuser` at uid 10001 (Ubuntu 24.04's
# base image already owns uid 1000 as `ubuntu`, so upstream moved it) and
# chowns /app to 10001. The entrypoint's PUID remapping only chowns
# /app/data + /app/whisperx-env, not /app itself, so running as 1000
# leaves the app unable to open its SQLite DB and it crash-loops with
# `unable to open database file: out of memory (14)` — which is
# SQLITE_CANTOPEN wearing a misleading message, not a real OOM.
# The host bind-mount dirs are therefore chowned to 10001:10001 too.
# Verified 2026-08-23: PUID=1000 crash-loops, PUID=10001 starts clean.
- PUID=${SCRIBERR_PUID:-10001}
- PGID=${SCRIBERR_PGID:-10001}
- APP_ENV=production
# Served over plain HTTP on the LAN. Left at the production default of
# `true`, the session cookie is marked Secure and the browser silently
# drops it — you log in, get bounced back to the login page, and the
# logs show nothing wrong. This must stay false while access is HTTP.
- SECURE_COOKIES=${SCRIBERR_SECURE_COOKIES:-false}
# Upstream defaults to localhost origins only, which fails CORS when
# reached by host IP. Keep this in sync with how the app is reached.
- ALLOWED_ORIGINS=${SCRIBERR_ALLOWED_ORIGINS}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
# Scriberr builds each model backend's Python env with `uv` at runtime.
# uv's default link mode reflink/hardlinks out of its cache, which fails
# on this overlayfs+ZFS combination with a misleading
# "Failed to clone ... Resource temporarily unavailable (os error 11)"
# and takes out the Parakeet + Sortformer backends (WhisperX survives).
# `copy` trades a little disk and time for it actually working.
- UV_LINK_MODE=${SCRIBERR_UV_LINK_MODE:-copy}
# ── Model caches on /tank, not in the container layer (Prime, 2026-10-06) ──
# Before this they lived under /home/appuser, which is the container's writable
# layer, so EVERY recreate threw away ~6 GB (Whisper large-v3, pyannote, the
# alignment model, NLTK) and the next job had to download it all again.
# /app/whisperx-env is the persistent /tank/scriberr/whisperx-env bind mount.
- HF_HOME=/app/whisperx-env/hf-cache
- TORCH_HOME=/app/whisperx-env/torch-cache
- NLTK_DATA=/app/whisperx-env/nltk_data
# ⚠ HF_TOKEN is a PLACEHOLDER on purpose. No real credential lives here.
# pyannote's diarization models are gated: downloading them needs a token whose
# account accepted their terms. LOADING an already-downloaded copy does not.
# Measured 2026-10-06: with an invalid token, huggingface_hub's check gets a 401
# and falls back to the cached copy, both online and with HF_HUB_OFFLINE=1.
# Public repos (any faster-whisper size) still download with an invalid token
# (HTTP 200). The placeholder exists only because Scriberr refuses to start
# pyannote diarization when HF_TOKEN is EMPTY (pyannote_adapter.go checks that
# it is non-empty, never that it is valid).
# To add a NEW gated model: run its first download once with a real token
# (vault `fv-ml1/hf-token`) set for that one run, then put the placeholder back.
- HF_TOKEN=${HF_TOKEN:-hf_placeholder_models_cached_on_tank}
# ── Memory settings, measured while Scriberr shared GPU 1 (2026-09-30; it moved to
# GPU 3 at 1322 the same day, so the 5.5 GB budget no longer binds, but the values stand). Prime: Scriberr shared GPU 1 with
# intern-decision, which holds ~9.7 GB resting / 10.3 GB peak). ──────────
# Parakeet's buffered path cuts audio into slices of this many seconds
# (Scriberr reads it in parakeet_adapter.go for BOTH the "is this long
# audio" threshold and --chunk-len; upstream default 300). Measured peak
# GPU memory on a 35-min file, n=3 each, deterministic:
# 300 s 9,384 MiB · 120 s 6,510 · 60 s 5,976 · 10 s 5,634 (fixed floor)
# 120 s + expandable_segments 5,496 · 60 s + expandable_segments 5,502
# The floor, not the slice, dominates below ~120 s; expandable_segments is
# what removes the fragmentation on top of it. 120 s + expandable fits
# beside intern-decision (9.0 GiB cap, 9,876 MiB card peak) even at both
# peaks: 9,876 + 5,496 = 15,372 of GPU 1's 15,442 MiB nvidia-smi Free
# (70 MiB spare; Free is NOT total−used, the driver reserves ~640 MiB). Transcripts
# change slightly: 95.8% word-sequence similarity vs 300 s (7,599 vs
# 7,645 words); the diffs are mostly casing/punctuation spread through
# the file, ~40 words at the 17 cuts. A-vs-A at 300 s: identical.
# Scriberr passes os.Environ() to the uv subprocess, so both reach NeMo.
- PARAKEET_CHUNK_THRESHOLD_SECS=${SCRIBERR_PARAKEET_CHUNK_SECS:-120}
- PYTORCH_CUDA_ALLOC_CONF=${SCRIBERR_PYTORCH_CUDA_ALLOC_CONF:-expandable_segments:True}
deploy:
resources:
reservations:
devices:
- driver: nvidia
device_ids: ["${SCRIBERR_GPU_ID:-3}"]
capabilities: [gpu]
healthcheck:
# 127.0.0.1 rather than localhost — the IPv6-first resolution trap has
# bitten news-digest and chatterbox in this fleet before.
# start_period is generous: first boot builds a Python env and pulls
# several GB of model weights before the port answers.
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/ >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 600s
networks:
- tnet
labels:
# ⚠ The group name MUST match a key in the dashboard's settings.yaml
# `layout:` block. A group that appears nowhere in that block gets no
# `tab:` assignment, and Homepage renders an untabbed group on EVERY tab.
# This label read `AI Systems` — a group that existed nowhere — from
# 2026-08-23 until it was caught on 2026-08-24.
# `AI - Studios` and not one of the ASR groups because Scriberr is a
# transcription UI you open and work in, which is what Studios collects;
# the bare ASR endpoints (Parakeet, Speaches) live in the collapsed
# `AI - Audio Tools` group instead.
- homepage.group=AI - Studios
- homepage.name=Scriberr
- homepage.icon=mdi-microphone-message
- homepage.description=Audio/video transcription + diarization (fv-ml1, GPU3)
# HTTPS via the fleet TLS caddy on nh3-dev (wildcard *.nh3.phasefinal.com). The in-browser
# recorder needs a SecureContext: getUserMedia is refused on http:// (2026-10-01). The
# plain http://10.251.50.54:8080 URL still works for everything except recording.
- homepage.href=https://scriberr.nh3.phasefinal.com
networks:
tnet:
name: traefik-net
external: true