diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a6303a6..e99b34c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,8 +7,29 @@ on:
branches: [main]
jobs:
+ changes:
+ name: Detect changes
+ runs-on: ubuntu-latest
+ outputs:
+ web: ${{ steps.filter.outputs.web }}
+ cli: ${{ steps.filter.outputs.cli }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dorny/paths-filter@v3
+ id: filter
+ with:
+ filters: |
+ web:
+ - 'web/**'
+ - '.github/workflows/ci.yml'
+ cli:
+ - 'cli/**'
+ - '.github/workflows/ci.yml'
+
cli:
name: CLI (pytest, ${{ matrix.installer }})
+ needs: changes
+ if: needs.changes.outputs.cli == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -56,6 +77,8 @@ jobs:
web:
name: Web (Karma)
+ needs: changes
+ if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
defaults:
run:
@@ -71,3 +94,84 @@ jobs:
run: npm ci
- name: Run tests
run: npx ng test --watch=false --browsers=ChromeHeadless
+
+ docker-web:
+ name: Docker (web)
+ needs: changes
+ if: needs.changes.outputs.web == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - name: Build image
+ uses: docker/build-push-action@v5
+ with:
+ context: ./web
+ load: true
+ tags: translora-web:ci
+ cache-from: type=gha,scope=docker-web
+ cache-to: type=gha,mode=max,scope=docker-web
+ - name: Smoke test
+ run: |
+ set -euo pipefail
+ docker run -d --name web-smoke -p 18080:80 translora-web:ci
+ for i in {1..15}; do
+ curl -fsS -o /dev/null http://localhost:18080/ && break
+ sleep 1
+ done
+
+ code=$(curl -s -o /tmp/index.html -w '%{http_code}' http://localhost:18080/)
+ [ "$code" = "200" ] || { echo "GET / returned $code"; exit 1; }
+ grep -q '
TransLora' /tmp/index.html
+
+ # SPA fallback: unknown paths should also return 200 + index.html.
+ code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18080/some/spa/route)
+ [ "$code" = "200" ] || { echo "SPA fallback returned $code"; exit 1; }
+
+ docker rm -f web-smoke
+
+ docker-cli:
+ name: Docker (cli)
+ needs: changes
+ if: needs.changes.outputs.cli == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - name: Build image
+ uses: docker/build-push-action@v5
+ with:
+ context: ./cli
+ load: true
+ tags: translora-cli:ci
+ cache-from: type=gha,scope=docker-cli
+ cache-to: type=gha,mode=max,scope=docker-cli
+ - name: Smoke test
+ run: |
+ set -euo pipefail
+ docker run --rm translora-cli:ci --version | grep -q 'TransLora CLI'
+ docker run --rm translora-cli:ci --help | grep -q '^usage:'
+
+ # Single status check for branch protection. Passes when every upstream
+ # job either succeeded or was legitimately skipped (path filter). Fails
+ # if any upstream actually failed or was cancelled.
+ ci:
+ name: CI
+ needs: [changes, cli, web, docker-web, docker-cli]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Verify upstream jobs
+ run: |
+ results='${{ toJson(needs) }}'
+ echo "$results"
+ echo "$results" | python3 -c '
+ import json, sys
+ data = json.load(sys.stdin)
+ bad = [name for name, job in data.items()
+ if job["result"] not in ("success", "skipped")]
+ if bad:
+ print(f"Failing upstream jobs: {bad}", file=sys.stderr)
+ sys.exit(1)
+ print("All upstream jobs OK.")
+ '
diff --git a/README.md b/README.md
index 1605784..8ef3764 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,86 @@ The defaults are tuned for best translation quality. On metered cloud providers
Set `NO_COLOR=1` to disable ANSI colors; output auto-falls back to plain lines when piped.
+## Docker
+
+Both interfaces ship with a `Dockerfile` so you can build and run without installing Node, Angular CLI, Python, or any deps locally.
+
+### Web app
+
+```bash
+# from the repo root
+docker build -t translora-web ./web
+docker run --rm -p 8080:80 translora-web
+```
+
+Open http://localhost:8080. The image is a small `nginx:alpine` serving the production Angular build, with SPA-fallback routing pre-configured.
+
+### CLI
+
+**Step 1 — build the image (one time):**
+
+```bash
+# from the repo root
+docker build -t translora-cli ./cli
+```
+
+**Step 2 — translate a file from your disk.**
+
+The image has no idea what's on your computer. To give it access to your subtitle files, you **mount a folder** from your disk into the container with `-v :/work`. Inside the container that folder appears as `/work`, and the CLI runs from there. Anything written to `/work` is written to your real folder — including the translated output.
+
+Picture it like this:
+
+```
+your computer inside the container
+────────────────────────────── ──────────────────────────────
+C:\Users\you\subs\movie.srt ◀───────▶ /work/movie.srt
+C:\Users\you\subs\movie.ar.srt ◀───────▶ /work/movie.ar.srt (output)
+ │
+ └── -v "C:\Users\you\subs:/work"
+```
+
+So the workflow is: `cd` into the folder containing your subtitle files, then run the container with `-v "$(pwd):/work"`. Pass file names exactly like you would to the local CLI — they resolve relative to `/work` automatically.
+
+**Cloud provider example (OpenAI, OpenRouter, Groq, …):**
+
+```bash
+cd /path/to/your/subtitles # the folder where movie.srt lives
+
+docker run --rm -v "$(pwd):/work" translora-cli movie.srt -t Arabic \
+ --api-url https://api.openai.com/v1/chat/completions \
+ --api-key sk-... --model gpt-4.1-mini
+```
+
+After this finishes, `movie.ar.srt` appears in the same folder on your disk. You can also pass a folder name to translate everything in it (`docker run ... translora-cli ./ -t Arabic ...`).
+
+**Path syntax cheat sheet for the `-v` flag:**
+
+| Shell | Use |
+|---|---|
+| Linux / macOS / Git Bash | `-v "$(pwd):/work"` |
+| Windows PowerShell | `-v "${PWD}:/work"` |
+| Windows cmd.exe | `-v "%cd%:/work"` |
+
+You can also pass an absolute path explicitly: `-v "C:\Users\you\subs:/work"` (Windows) or `-v "/home/you/subs:/work"` (Linux).
+
+**Local LLM server on your host machine.**
+
+If you're running an LLM server on your own computer (e.g. on `http://127.0.0.1:8080`), `127.0.0.1` from inside the container points at the container itself, not your host. Use `host.docker.internal` instead. On Linux you also need `--add-host=host.docker.internal:host-gateway`:
+
+```bash
+docker run --rm -v "$(pwd):/work" \
+ --add-host=host.docker.internal:host-gateway \
+ translora-cli movie.srt -t Arabic \
+ --api-url http://host.docker.internal:8080/v1/chat/completions
+```
+
+(`--add-host` is harmless on Mac and Windows where Docker Desktop maps `host.docker.internal` automatically — leave it in for cross-platform copy/paste.)
+
+### Notes
+
+- `--rm` deletes the container after it exits so they don't pile up. Drop it if you want to keep the container around for debugging.
+- Both Dockerfiles use BuildKit cache mounts for `npm` and `pip`, so re-builds after a small code change finish in a few seconds.
+
## How it works
Small and medium LLMs have known failure modes on long subtitle files: skipping one-word blocks (`"Oh!"`, `"Hmm."`), merging sentences split across two blocks for timing, drifting mid-file, and switching dialect or formality between batches. TransLora defends against that with a six-step pipeline:
diff --git a/cli/.dockerignore b/cli/.dockerignore
new file mode 100644
index 0000000..c15718d
--- /dev/null
+++ b/cli/.dockerignore
@@ -0,0 +1,15 @@
+__pycache__
+**/__pycache__
+*.pyc
+*.pyo
+.pytest_cache
+.venv
+.vscode
+.git
+.gitignore
+tests
+*.md
+Dockerfile*
+.dockerignore
+uv.lock
+**/*.log
diff --git a/cli/Dockerfile b/cli/Dockerfile
new file mode 100644
index 0000000..4d9a6b5
--- /dev/null
+++ b/cli/Dockerfile
@@ -0,0 +1,25 @@
+# syntax=docker/dockerfile:1.7
+
+FROM python:3.12-alpine AS runtime
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUTF8=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
+
+WORKDIR /app
+
+# Install deps first so source-only edits don't bust the pip cache layer.
+# All deps are pure-Python wheels — alpine works without compilers.
+COPY requirements.txt ./
+RUN --mount=type=cache,target=/root/.cache/pip \
+ pip install --no-compile -r requirements.txt \
+ && find /usr/local/lib/python3.12 -name '__pycache__' -type d -exec rm -rf {} + \
+ && find /usr/local/lib/python3.12 -name 'tests' -type d -exec rm -rf {} +
+
+COPY core ./core
+COPY translora.py ./translora.py
+
+# Subtitle files live in the user's mount, not in the image.
+WORKDIR /work
+ENTRYPOINT ["python", "/app/translora.py"]
diff --git a/web/.dockerignore b/web/.dockerignore
new file mode 100644
index 0000000..b9f098b
--- /dev/null
+++ b/web/.dockerignore
@@ -0,0 +1,12 @@
+node_modules
+dist
+.angular
+.vscode
+.git
+.gitignore
+*.md
+Dockerfile*
+.dockerignore
+**/*.log
+.env
+.env.*
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..2375450
--- /dev/null
+++ b/web/Dockerfile
@@ -0,0 +1,20 @@
+# syntax=docker/dockerfile:1.7
+
+# ---- build stage ---------------------------------------------------------
+FROM node:20-alpine AS build
+WORKDIR /app
+
+# Install deps first so source-only changes don't bust the npm cache layer.
+COPY package.json package-lock.json ./
+RUN --mount=type=cache,target=/root/.npm \
+ npm ci --prefer-offline --no-audit --no-fund
+
+COPY . .
+RUN npx ng build --configuration production
+
+# ---- runtime stage -------------------------------------------------------
+FROM nginx:1.27-alpine-slim AS runtime
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist/web/browser /usr/share/nginx/html
+
+EXPOSE 80
diff --git a/web/nginx.conf b/web/nginx.conf
new file mode 100644
index 0000000..2f6734b
--- /dev/null
+++ b/web/nginx.conf
@@ -0,0 +1,23 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # SPA fallback: every unknown path resolves to index.html.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Long-cache hashed static assets.
+ location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpe?g|gif|ico|webp)$ {
+ expires 30d;
+ add_header Cache-Control "public, no-transform";
+ try_files $uri =404;
+ }
+
+ # Never cache index.html itself — it points at hashed bundles.
+ location = /index.html {
+ add_header Cache-Control "no-store";
+ }
+}