diff --git a/dns/internal.yaml b/dns/internal.yaml index 2763c02..e2390a6 100644 --- a/dns/internal.yaml +++ b/dns/internal.yaml @@ -108,3 +108,4 @@ aliases: - {name: gateway, site: ana, target: ana-docker, note: LiteLLM gateway :4000} - {name: booth, site: nh3, target: nh3-dev, note: The Booth :8090} - {name: homepage, site: esh, target: esh-docker-vm, note: fleet dashboard :5100} + - {name: scriberr, site: ana, target: ana-ml2, note: transcription + diarization :8080 (GPU1)} diff --git a/stacks/scriberr/.env.example b/stacks/scriberr/.env.example new file mode 100644 index 0000000..cb7e438 --- /dev/null +++ b/stacks/scriberr/.env.example @@ -0,0 +1,44 @@ +# Scriberr — copy to .env on the host at /opt/docker/compose/scriberr/.env +# Real .env is gitignored and lives only on ana-ml2. + +# ── Image ──────────────────────────────────────────────────────────────── +# Built locally from Dockerfile.cuda.12.9 — see the compose header for why +# the published scriberr-cuda image is NOT usable on these Blackwell cards. +SCRIBERR_IMAGE=scriberr:local-blackwell + +# ── Network ────────────────────────────────────────────────────────────── +SCRIBERR_PORT=8080 +SCRIBERR_BIND=0.0.0.0 +# CORS. Must list every origin the UI is actually reached from, or the +# browser blocks the API calls. Comma-separated, no spaces, no trailing /. +SCRIBERR_ALLOWED_ORIGINS=http://10.250.50.54:8080,http://scriberr.ana.internal:8080 + +# ── GPU ────────────────────────────────────────────────────────────────── +# GPU0 is fully committed to the `gen` seat; GPU1 is the one with headroom. +SCRIBERR_GPU_ID=1 + +# ── Storage (on /tank — NOT the root pool, weights are multi-GB) ───────── +SCRIBERR_DATA_DIR=/tank/scriberr/data +SCRIBERR_ENV_DIR=/tank/scriberr/whisperx-env + +# ── Runtime ────────────────────────────────────────────────────────────── +# ⚠ 10001, not the fleet-usual 1000. The Blackwell image's `appuser` IS 10001 +# and its PUID remapping is broken — at 1000 the app cannot open its SQLite DB +# and crash-loops. The /tank dirs are chowned to 10001:10001 to match. +# See README "The PUID trap". +SCRIBERR_PUID=10001 +SCRIBERR_PGID=10001 +# Keep false while the app is served over plain HTTP. Setting this true +# without TLS makes login silently fail (cookie marked Secure, dropped). +SCRIBERR_SECURE_COOKIES=false + +# ── Optional: summarisation / transcript chat ──────────────────────────── +# Scriberr speaks the OpenAI API. Point it at the LiteLLM gateway so this +# costs nothing and stays on-prem, rather than a paid vendor key. +# Configure the base URL in the Scriberr UI (Settings -> AI provider): +# base URL : http://10.250.50.70:4000/v1 +# model : summarizer (or gen / gen-reasoning) +# The key below is the shared all-agents gateway key. +# ⚠ That key also reaches PAID passthrough models (GLM, Kimi) on a shared +# tab — keep the configured model on a free local seat. +# SCRIBERR_OPENAI_API_KEY= diff --git a/stacks/scriberr/README.md b/stacks/scriberr/README.md new file mode 100644 index 0000000..edf19b8 --- /dev/null +++ b/stacks/scriberr/README.md @@ -0,0 +1,135 @@ +# scriberr — self-hosted transcription + diarization (ana-ml2, GPU1) + +Web UI for transcribing audio/video locally. WhisperX (Whisper + pyannote +speaker diarization) with NVIDIA Parakeet/Canary also selectable; SQLite for +state; optional summarisation and transcript chat against any OpenAI-compatible +endpoint. + +- **Host:** `ana-ml2` (10.250.50.54) — GPU1 +- **URL:** http://10.250.50.54:8080 +- **Upstream:** https://github.com/rishikanthc/Scriberr + +## The image is built locally, and that is not incidental + +ana-ml2's RTX PRO 6000 Blackwell cards are **sm_120**. Upstream's published +images do not cover that: + +| image | built for | usable here | +|---|---|---| +| `ghcr.io/rishikanthc/scriberr` | CPU | yes, but no GPU | +| `ghcr.io/rishikanthc/scriberr-cuda` | sm_61 … sm_89 (Pascal→Ada) | **no** — no sm_120 kernels | +| `ghcr.io/rishikanthc/scriberr-cuda-blackwell` | sm_120 | **does not exist** — documented in the upstream README but never published; GHCR returns no tags (checked 2026-08-23) | + +The sm_120 path upstream actually ships is `Dockerfile.cuda.12.9` +(CUDA 12.9.1 + cuDNN, `PYTORCH_CUDA_VERSION=cu128`), built from source. So we +build it. **Do not "simplify" the compose back to the published `scriberr-cuda` +image** — it will fail on these cards or quietly fall back to CPU. + +### Rebuilding + +```bash +ssh ana-ml2 +cd /tank/scriberr/src/Scriberr +git pull +docker build -f Dockerfile.cuda.12.9 -t scriberr:local-blackwell . +cd /opt/docker/compose/scriberr && docker compose up -d +``` + +Source checkout lives on `/tank`, not the root pool — see storage below. + +## Deploy + +```bash +# from this workstation +scripts/deploy-stack.sh ana-ml2 scriberr +``` + +Then on the host, the usual: + +```bash +cd /opt/docker/compose/scriberr +docker compose config # dry parse first +docker compose up -d scriberr # target the service, not the whole stack +``` + +## Storage — deliberately on /tank + +`/var/lib/docker` on ana-ml2 sits on `zroot` at ~87% used. Whisper, pyannote +and NeMo weights are multi-GB and land in the `whisperx-env` volume, so both +mounts are bind-mounted onto `/tank` (4+ TB) instead of named volumes: + +| host path | container path | holds | +|---|---|---| +| `/tank/scriberr/data` | `/app/data` | SQLite DB, uploads, transcripts | +| `/tank/scriberr/whisperx-env` | `/app/whisperx-env` | Python env + model weights | +| `/tank/scriberr/src/Scriberr` | — | build checkout | + +Both are owned by uid/gid 1000 to match `PUID`/`PGID`. + +## First run takes a while + +On first start the container builds a Python environment and downloads several +GB of model weights before the port answers — upstream says "several minutes". +The healthcheck therefore has a **600 s `start_period`**; the container will +show `starting`, not `unhealthy`, during that window. Watch it with: + +```bash +docker logs -f scriberr +``` + +Subsequent starts are fast because the env volume persists. + +## The PUID trap — read this before "fixing" the uid + +This stack runs as **uid/gid 10001**, not the fleet-usual 1000, and the +`/tank/scriberr` dirs are chowned to match. That is deliberate. + +`Dockerfile.cuda.12.9` creates `appuser` at **uid 10001** — Ubuntu 24.04's base +image already owns uid 1000 as `ubuntu`, so upstream moved their app user out of +the way. It then `chown`s `/app` to 10001. But the entrypoint's `PUID` remapping +only chowns `/app/data` and `/app/whisperx-env` — **not `/app` itself**. So +running with `PUID=1000` leaves the app unable to open its SQLite database and +it crash-loops with: + +``` +Failed to connect to database: unable to open database file: out of memory (14) +``` + +That message is a red herring twice over: error 14 is `SQLITE_CANTOPEN`, not an +OOM, and the machine has 566 GB of RAM. Diagnosis notes from 2026-08-23: + +- SQLite itself writes fine to `/tank` as uid 1000 — the mount is not at fault. +- The app fails on a plain Docker **named volume** too — storage is not at fault. +- The **published CPU image runs fine at `PUID=1000`**, because in `Dockerfile` + (the non-CUDA one) `appuser` *is* uid 1000. Only the CUDA 12.9 variant moved it. +- Same image at `PUID=10001` starts clean. That is the whole difference. + +If you ever want host files owned by 1000 instead, the fix is to patch +`Dockerfile.cuda.12.9` to `userdel ubuntu` and recreate `appuser` at 1000, then +rebuild — a local patch to carry, which is why it was not done. + +## Gotchas + +- **`SECURE_COOKIES` must stay `false` while served over plain HTTP.** At the + production default of `true` the session cookie is marked `Secure`, the + browser drops it, and login appears to succeed then bounces you straight back + to the login page with nothing useful in the logs. +- **`ALLOWED_ORIGINS` must list the real origin.** Upstream defaults to + `localhost` only; reaching the UI by host IP fails CORS until it is set. +- **Never add `NVIDIA_VISIBLE_DEVICES=all`.** Upstream's compose sets it, but + here it would override the `device_ids` reservation and expose both cards — + GPU0 belongs to the `gen` seat. +- **This stack is a guest on GPU1**, which it shares with the `sec` seat. If + VRAM gets tight, this is the thing that should yield. + +## Optional: summarisation via the LiteLLM gateway + +Scriberr speaks the OpenAI API, so point it at the fleet gateway instead of a +paid vendor. In the UI under the AI provider settings: + +- base URL: `http://10.250.50.70:4000/v1` +- model: `summarizer` (or `gen` / `gen-reasoning`) +- key: the shared all-agents gateway key + +⚠ That key also reaches **paid** passthrough models (GLM, Kimi) on a shared +tab. Keep the configured model on a free local seat. diff --git a/stacks/scriberr/compose.yaml b/stacks/scriberr/compose.yaml new file mode 100644 index 0000000..eb40a0c --- /dev/null +++ b/stacks/scriberr/compose.yaml @@ -0,0 +1,95 @@ +# 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 ────────────────────────────────────── +# ana-ml2'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 ana-ml2. +# +# ── GPU PINNING ─────────────────────────────────────────────────────────── +# Pinned to **GPU1** via explicit device_ids, per the house convention and +# because GPU0 is fully committed to the `gen` seat. GPU1 shares space with +# the `sec` seat, so this stack is a guest there — keep an eye on VRAM. +# NOTE: do NOT add `NVIDIA_VISIBLE_DEVICES=all` (as upstream's compose does). +# It overrides the device_ids reservation and exposes both cards. +# +# 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 ana-ml2 + # 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 + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["${SCRIBERR_GPU_ID:-1}"] + 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: + - homepage.group=AI Systems + - homepage.name=Scriberr + - homepage.icon=mdi-microphone-message + - homepage.description=Audio/video transcription + diarization (ana-ml2, GPU1) + - homepage.href=http://10.250.50.54:${SCRIBERR_PORT} + +networks: + tnet: + name: traefik-net + external: true