commit 4eec2598a84abca1b7b919219cf401d83bad95ef Author: trav Date: Mon Oct 12 16:46:18 2026 -0700 feat: caption-ninja recipe (web pages, private relay, Caption Local STT) Self-hosted live captioning from steveseguin/captionninja and steveseguin/caption-local. Upstream publishes no images, so images/build.sh builds them from pinned commits. diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..8977198 --- /dev/null +++ b/.env.sample @@ -0,0 +1,39 @@ +TYPE=caption-ninja +# First deploy downloads the Whisper model; give abra time to converge. +TIMEOUT=900 + +# caption.ninja pages (capture, editor, overlay) and the private relay at /relay +DOMAIN=caption-ninja.example.com +# Caption Local speech-to-text service (needs its own hostname: it uses absolute paths) +STT_DOMAIN=stt-caption-ninja.example.com + +LETS_ENCRYPT_ENV=production + +# Relay room tokens: 32-128 URL-safe chars, all four must differ. +# "source" = capture -> editor, "output" = editor -> overlay/OBS. +SECRET_SRC_READ_VERSION=v1 # length=48 +SECRET_SRC_WRITE_VERSION=v1 # length=48 +SECRET_OUT_READ_VERSION=v1 # length=48 +SECRET_OUT_WRITE_VERSION=v1 # length=48 +# Caption Local shared service token (>= 24 chars, no whitespace) +SECRET_STT_API_KEY_VERSION=v1 # length=48 + +## Speech-to-text tuning (CPU). See upstream docs/DEPLOYMENT-PROFILES.md. +## Whisper model: tiny, base, small (default), medium, large-v3. Bigger = more RAM + CPU. +#CAPTION_MODEL=small +## Decoder beam width 1-5; 1 is fastest +#CAPTION_BEAM_SIZE=5 +## Concurrent model workers; threads are per worker. Keep workers*threads <= cores. +#CAPTION_WORKERS=1 +#CAPTION_THREADS=4 +#CAPTION_MAX_STREAMS=12 +## Set to 1 once the model has downloaded into the volume, to stop Hugging Face lookups +#CAPTION_OFFLINE=0 +## Metadata-only request logs (no audio/text/tokens) +#CAPTION_LOG_REQUESTS=0 + +## Extra exact origins (comma separated, e.g. https://steveseguin.github.io) +#RELAY_EXTRA_ORIGINS= +#STT_EXTRA_ORIGINS= +## Max relay sockets per client IP (everyone shares Traefik's IP) +#RELAY_PER_IP=512 diff --git a/README.md b/README.md new file mode 100644 index 0000000..12db061 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# CAPTION.Ninja + Caption Local + +Self-hosted live captioning: [CAPTION.Ninja](https://github.com/steveseguin/captionninja)'s +capture/editor/overlay pages and private caption relay, plus +[Caption Local](https://github.com/steveseguin/caption-local) Whisper speech-to-text. +Nothing is sent to caption.ninja, Google or any cloud STT provider. + + +* **Category**: Apps +* **Status**: 1, alpha +* **Image**: [`git.autonomic.zone/recipe-maintainers/caption-ninja-*`](https://git.autonomic.zone/recipe-maintainers/-/packages), 1, built from upstream source (see [Images](#images)) +* **Healthcheck**: Yes +* **Backups**: No (no user data; the model volume is a cache) +* **Email**: No +* **Tests**: 3 +* **SSO**: No + + +## Services + +| Service | Hostname | What | +| --- | --- | --- | +| `app` | `$DOMAIN` | nginx serving the caption.ninja pages | +| `relay` | `wss://$DOMAIN/relay` | private caption relay (Node, `ws`), rooms `source` and `output` | +| `stt` | `$STT_DOMAIN` | Caption Local (faster-whisper, CPU), also serves its own capture page | + +Captions flow: **capture** (microphone → STT) publishes to room `source` → **editor** +reviews and forwards to room `output` → **overlay** (OBS browser source / audience). + +## Basic usage + +1. Set up Docker Swarm, [`abra`][abra] and [`coop-cloud/traefik`][cc-traefik] +2. Make the images available on the server (see [Images](#images)) +3. `abra app new caption-ninja --secrets` +4. `abra app config YOURAPPDOMAIN`: set `DOMAIN` and `STT_DOMAIN` (both need DNS). + The STT host needs RAM for the model: about 1.5 GB for `small`. Set `CAPTION_THREADS` to at most your core count. +5. `abra app deploy YOURAPPDOMAIN`. On first start `stt` downloads the Whisper + model into the `models` volume, so it can take several minutes to turn healthy. + +## Using it + +Read the tokens (keep them private; read tokens are for viewers, write tokens publish): + +``` +abra app run YOURAPPDOMAIN relay -- sh -c 'for s in src_read src_write out_read out_write; do echo "$s=$(cat /run/secrets/$s)"; done' +abra app run YOURAPPDOMAIN stt -- cat /run/secrets/stt_api_key +``` + +1. Open `https://$STT_DOMAIN/`, enter the `stt_api_key` service token, then pick a microphone. +2. Under **Send captions to caption.ninja → Use a private relay**, enter: + relay `wss://$DOMAIN/relay`, caption website `https://$DOMAIN/`, source room + `source` + `src_write` token, editor output room `output`. Enable sharing. +3. Open the generated **editor** link and enter `src_read` and `out_write`. +4. In the editor, create the view-only OBS/overlay link with `out_read`. + +Alternatively use `https://$DOMAIN/capture-local.html` with service address +`https://$STT_DOMAIN`. + +**Note:** the other caption.ninja pages (`index.html`, `translate.html`, …) still +default to the public relay `wss://api.caption.ninja`. Only `capture-local.html`, +`editor.html` and `overlay.html` support the private relay. + +## Images + +Upstream publishes no Docker images, so `images/build.sh` builds them from pinned +upstream commits. It needs Docker, git and no registry credentials: + +``` +scp -r images SERVER:caption-ninja-build/ +ssh SERVER 'bash caption-ninja-build/images/build.sh' +``` + +The images are tagged with their registry names, so a single-node swarm can deploy +straight from the local tags. To publish them to the registry from a machine +that has `docker login git.autonomic.zone`: + +``` +for img in $(ssh SERVER "docker images --format '{{.Repository}}:{{.Tag}}' | grep caption-ninja-"); do + ssh SERVER docker save "$img" | docker load && docker push "$img" +done +``` + +To upgrade, bump the commits in `images/build.sh` and the tags in `compose.yml`. + +## Security notes + +* Caption Local only accepts `Host: localhost` (upstream expects an SSH tunnel), so + Traefik rewrites the Host header for `$STT_DOMAIN`; `stt` is still only reachable + through Traefik, and every API call needs the token. +* The STT token is a single shared token, not per-user auth. Anyone with it can use + your CPU for transcription. Rotate it with `abra app secret` + redeploy. +* Relay origins are `https://$DOMAIN` and `https://$STT_DOMAIN`; add more exact + origins with `RELAY_EXTRA_ORIGINS` (e.g. `https://steveseguin.github.io`). +* To revoke relay tokens, generate new secret versions and redeploy (restart + disconnects all clients and clears the 2-minute replay buffer). + +[abra]: https://git.coopcloud.tech/coop-cloud/abra +[cc-traefik]: https://git.coopcloud.tech/coop-cloud/traefik diff --git a/abra.sh b/abra.sh new file mode 100644 index 0000000..a25380e --- /dev/null +++ b/abra.sh @@ -0,0 +1,3 @@ +export NGINX_CONF_VERSION=v1 +export RELAY_ENTRYPOINT_VERSION=v1 +export STT_ENTRYPOINT_VERSION=v2 diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..b92cbd4 --- /dev/null +++ b/compose.yml @@ -0,0 +1,153 @@ +--- +version: "3.8" + +services: + app: + image: git.autonomic.zone/recipe-maintainers/caption-ninja-web:2026.09.07-dfcee8f + networks: + - proxy + configs: + - source: nginx_conf + target: /etc/nginx/conf.d/default.conf + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + deploy: + update_config: + failure_action: rollback + order: start-first + labels: + - "traefik.enable=true" + - "traefik.http.routers.${STACK_NAME}.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.${STACK_NAME}.entrypoints=web-secure" + - "traefik.http.routers.${STACK_NAME}.tls.certresolver=${LETS_ENCRYPT_ENV}" + - "traefik.http.services.${STACK_NAME}.loadbalancer.server.port=80" + - "coop-cloud.${STACK_NAME}.version=0.1.0+v2026.09.07" + + relay: + image: git.autonomic.zone/recipe-maintainers/caption-ninja-relay:0.2.0-dfcee8f + networks: + - proxy + environment: + - DOMAIN + - STT_DOMAIN + - RELAY_EXTRA_ORIGINS + - RELAY_PER_IP + secrets: + - src_read + - src_write + - out_read + - out_write + configs: + - source: relay_entrypoint + target: /relay-entrypoint.sh + mode: 0555 + entrypoint: /relay-entrypoint.sh + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + deploy: + update_config: + failure_action: rollback + order: start-first + labels: + - "traefik.enable=true" + - "traefik.http.routers.${STACK_NAME}_relay.rule=Host(`${DOMAIN}`) && Path(`/relay`)" + - "traefik.http.routers.${STACK_NAME}_relay.entrypoints=web-secure" + - "traefik.http.routers.${STACK_NAME}_relay.tls.certresolver=${LETS_ENCRYPT_ENV}" + - "traefik.http.routers.${STACK_NAME}_relay.service=${STACK_NAME}_relay" + - "traefik.http.routers.${STACK_NAME}_relay.middlewares=${STACK_NAME}_relay_strip" + - "traefik.http.middlewares.${STACK_NAME}_relay_strip.stripprefix.prefixes=/relay" + - "traefik.http.services.${STACK_NAME}_relay.loadbalancer.server.port=8787" + + stt: + image: git.autonomic.zone/recipe-maintainers/caption-ninja-stt:1.1.0-7d759fe + networks: + - proxy + environment: + - DOMAIN + - STT_DOMAIN + - STT_EXTRA_ORIGINS + - CAPTION_MODEL=${CAPTION_MODEL:-small} + - CAPTION_BEAM_SIZE=${CAPTION_BEAM_SIZE:-5} + - CAPTION_WORKERS=${CAPTION_WORKERS:-1} + - CAPTION_THREADS=${CAPTION_THREADS:-4} + - CAPTION_MAX_STREAMS=${CAPTION_MAX_STREAMS:-12} + - CAPTION_COMPUTE_TYPE=${CAPTION_COMPUTE_TYPE:-auto} + - CAPTION_DEVICE=cpu + - CAPTION_OFFLINE=${CAPTION_OFFLINE:-0} + - CAPTION_LOG_REQUESTS=${CAPTION_LOG_REQUESTS:-0} + secrets: + - stt_api_key + configs: + - source: stt_entrypoint + target: /stt-entrypoint.sh + mode: 0555 + entrypoint: /stt-entrypoint.sh + volumes: + - models:/models + healthcheck: + # The image's own healthcheck reads CAPTION_API_KEY from the environment, + # which only the entrypoint process has; feed it the secret here. + test: ["CMD", "sh", "-c", "CAPTION_API_KEY=$$(cat /run/secrets/stt_api_key) python healthcheck.py"] + interval: 30s + timeout: 10s + retries: 3 + # First start downloads the Whisper model into the volume. + start_period: 600s + stop_grace_period: 100s + deploy: + update_config: + failure_action: rollback + order: stop-first + labels: + - "traefik.enable=true" + - "traefik.http.routers.${STACK_NAME}_stt.rule=Host(`${STT_DOMAIN}`)" + - "traefik.http.routers.${STACK_NAME}_stt.entrypoints=web-secure" + - "traefik.http.routers.${STACK_NAME}_stt.tls.certresolver=${LETS_ENCRYPT_ENV}" + - "traefik.http.routers.${STACK_NAME}_stt.service=${STACK_NAME}_stt" + # Caption Local only accepts Host: localhost (upstream expects an SSH tunnel) + - "traefik.http.routers.${STACK_NAME}_stt.middlewares=${STACK_NAME}_stt_host" + - "traefik.http.middlewares.${STACK_NAME}_stt_host.headers.customrequestheaders.Host=localhost" + - "traefik.http.services.${STACK_NAME}_stt.loadbalancer.server.port=8765" + +secrets: + src_read: + name: ${STACK_NAME}_src_read_${SECRET_SRC_READ_VERSION} + external: true + src_write: + name: ${STACK_NAME}_src_write_${SECRET_SRC_WRITE_VERSION} + external: true + out_read: + name: ${STACK_NAME}_out_read_${SECRET_OUT_READ_VERSION} + external: true + out_write: + name: ${STACK_NAME}_out_write_${SECRET_OUT_WRITE_VERSION} + external: true + stt_api_key: + name: ${STACK_NAME}_stt_api_key_${SECRET_STT_API_KEY_VERSION} + external: true + +configs: + nginx_conf: + name: ${STACK_NAME}_nginx_conf_${NGINX_CONF_VERSION} + file: nginx.conf + relay_entrypoint: + name: ${STACK_NAME}_relay_entrypoint_${RELAY_ENTRYPOINT_VERSION} + file: relay-entrypoint.sh + stt_entrypoint: + name: ${STACK_NAME}_stt_entrypoint_${STT_ENTRYPOINT_VERSION} + file: stt-entrypoint.sh + +networks: + proxy: + external: true + +volumes: + models: diff --git a/images/build.sh b/images/build.sh new file mode 100755 index 0000000..ac82c7b --- /dev/null +++ b/images/build.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Build the three caption-ninja images from pinned upstream commits. +# +# Upstream publishes no images, so we build them ourselves. Run this on a +# Docker host (it needs no registry credentials); the images are tagged with +# their final registry names so a single-node swarm can deploy them directly. +# See README.md "Images" for pushing them to the registry afterwards. +# +# usage: images/build.sh [workdir] +set -euo pipefail + +REGISTRY=git.autonomic.zone/recipe-maintainers +CAPTIONNINJA_COMMIT=dfcee8f551a2b175740ab9f73e3787e1458cce2d +CAPTION_LOCAL_COMMIT=7d759fe1eae100ccdb5de0eea82438c941b26582 +RELAY_VERSION=0.2.0 # captionninja relay/package.json +CAPTION_LOCAL_VERSION=1.1.0 # caption-local VERSION +WEB_VERSION=2026.09.07 # captionninja has no releases: date of CAPTIONNINJA_COMMIT + +here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +work="${1:-$(mktemp -d)}" + +checkout() { # url commit dir + rm -rf "$3" + git init -q "$3" + git -C "$3" fetch -q --depth 1 "$1" "$2" + git -C "$3" checkout -q FETCH_HEAD +} + +checkout https://github.com/steveseguin/captionninja "$CAPTIONNINJA_COMMIT" "$work/captionninja" +checkout https://github.com/steveseguin/caption-local "$CAPTION_LOCAL_COMMIT" "$work/caption-local" + +cn="${CAPTIONNINJA_COMMIT:0:7}" +cl="${CAPTION_LOCAL_COMMIT:0:7}" + +docker build -t "$REGISTRY/caption-ninja-web:$WEB_VERSION-$cn" \ + -f "$here/web/Dockerfile" "$work/captionninja" +docker build -t "$REGISTRY/caption-ninja-relay:$RELAY_VERSION-$cn" \ + "$work/captionninja/relay" +docker build -t "$REGISTRY/caption-ninja-stt:$CAPTION_LOCAL_VERSION-$cl" \ + "$work/caption-local" + +echo +echo "Built:" +echo " $REGISTRY/caption-ninja-web:$WEB_VERSION-$cn" +echo " $REGISTRY/caption-ninja-relay:$RELAY_VERSION-$cn" +echo " $REGISTRY/caption-ninja-stt:$CAPTION_LOCAL_VERSION-$cl" diff --git a/images/web/Dockerfile b/images/web/Dockerfile new file mode 100644 index 0000000..fe1d3b3 --- /dev/null +++ b/images/web/Dockerfile @@ -0,0 +1,7 @@ +# Static caption.ninja pages. Build context: a captionninja checkout. +FROM alpine:3.22 AS src +COPY . /src +RUN cd /src && rm -rf .git .github relay scripts *.md test-security-warning.html + +FROM nginx:1.31.6-alpine +COPY --from=src /src /usr/share/nginx/html diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ef6d310 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + listen [::]:80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # caption.ninja links omit the extension (e.g. /overlay?room=...). + location / { + try_files $uri $uri.html $uri/ =404; + } + + # Room tokens can arrive in query strings on some pages; keep them out of logs. + access_log off; +} diff --git a/relay-entrypoint.sh b/relay-entrypoint.sh new file mode 100755 index 0000000..f59bab6 --- /dev/null +++ b/relay-entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -e + +# The relay reads one private JSON file holding allowed origins and per-room +# tokens. Build it from the Docker secrets at startup so tokens never live in +# the recipe or the env file. Writes to /tmp, readable only by the node user. +umask 077 +node - > /tmp/rooms.json <<'JS' +const fs = require('node:fs'); +const secret = name => fs.readFileSync('/run/secrets/' + name, 'utf8').trim(); +const origins = [process.env.DOMAIN, process.env.STT_DOMAIN] + .filter(Boolean).map(host => 'https://' + host) + .concat((process.env.RELAY_EXTRA_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean)); +const config = { + origins: [...new Set(origins)], + rooms: { + source: {read: secret('src_read'), write: secret('src_write')}, + output: {read: secret('out_read'), write: secret('out_write')}, + }, + // Every client arrives via Traefik, so they all share one source IP; the + // upstream default of 128 sockets per IP would cap the whole audience. + limits: {perIp: Number(process.env.RELAY_PER_IP || 512)}, +}; +process.stdout.write(JSON.stringify(config)); +JS + +export CAPTION_RELAY_CONFIG=/tmp/rooms.json +exec node server.cjs diff --git a/stt-entrypoint.sh b/stt-entrypoint.sh new file mode 100755 index 0000000..1435049 --- /dev/null +++ b/stt-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# Caption Local takes its shared service token from CAPTION_API_KEY only. +if [ -f /run/secrets/stt_api_key ]; then + export CAPTION_API_KEY="$(cat /run/secrets/stt_api_key)" +fi + +# Let the caption.ninja pages on $DOMAIN call this service, plus any extras. +# Its own page on $STT_DOMAIN must be listed too: Traefik rewrites Host to +# localhost, so the app no longer sees that page as same-origin. +origins="https://${DOMAIN},https://${STT_DOMAIN}" +[ -n "$STT_EXTRA_ORIGINS" ] && origins="${origins},${STT_EXTRA_ORIGINS}" +export CAPTION_ALLOWED_ORIGINS="$origins" + +exec python server.py --host 0.0.0.0 "$@"