Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,6 +77,8 @@ jobs:

web:
name: Web (Karma)
needs: changes
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -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 '<title>TransLora</title>' /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
Comment on lines +115 to +131
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docker-web smoke test only removes the web-smoke container at the end of the script. If any earlier command fails (e.g., curl/grep), set -e will exit and the container will be left running, which can leak resources and interfere with later steps. Add an EXIT trap (or use docker run --rm with a background-friendly pattern) to ensure cleanup happens even on failure.

Copilot uses AI. Check for mistakes.

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.")
'
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README claims the web Docker image is based on nginx:alpine, but web/Dockerfile actually uses nginx:1.27-alpine-slim. This can confuse users trying to reason about image size/behavior; please update the README to match the actual base image (or switch the Dockerfile base image to nginx:alpine if that’s the intent).

Suggested change
Open http://localhost:8080. The image is a small `nginx:alpine` serving the production Angular build, with SPA-fallback routing pre-configured.
Open http://localhost:8080. The image is a small `nginx:1.27-alpine-slim` serving the production Angular build, with SPA-fallback routing pre-configured.

Copilot uses AI. Check for mistakes.

### 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 <host-folder>:/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:
Expand Down
15 changes: 15 additions & 0 deletions cli/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
__pycache__
**/__pycache__
*.pyc
*.pyo
.pytest_cache
.venv
.vscode
.git
.gitignore
tests
*.md
Dockerfile*
.dockerignore
uv.lock
**/*.log
25 changes: 25 additions & 0 deletions cli/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
12 changes: 12 additions & 0 deletions web/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules
dist
.angular
.vscode
.git
.gitignore
*.md
Dockerfile*
.dockerignore
**/*.log
.env
.env.*
20 changes: 20 additions & 0 deletions web/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions web/nginx.conf
Original file line number Diff line number Diff line change
@@ -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";
}
}
Loading