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"; + } +}