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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@ TRANSCRIBER_MODEL=whisper-large-v3-turbo
# groq:openai/gpt-oss-120b (cloud, default)
# groq:llama-3.3-70b-versatile (cloud, stable)
# openai:gpt-4o-mini (cloud, reliable)
# openai:bilingualsub-gemini-flash (Docker Compose via CLIProxyAPI)
# ollama:TwinkleAI/gemma-3-4B-T1-it (local, free)
TRANSLATOR_MODEL=groq:openai/gpt-oss-120b

# === Optional CLIProxyAPI (used only by docker-compose.yml) ===
# Needed only if you want translations to route through CLIProxyAPI/agy.
# 1. Run host OAuth login first: cliproxyapi -antigravity-login
# 2. docker-compose.yml mounts this auth directory into the cli-proxy container.
# Leave CLIPROXY_AUTH_DIR unset to use ${HOME}/.cli-proxy-api.
# Set an absolute path only if your CLIProxyAPI auth directory is elsewhere.
# CLIPROXY_AUTH_DIR=/Users/you/.cli-proxy-api
CLIPROXY_PORT=8317
BILINGUALSUB_PORT=7860

# === API Keys (only needed for cloud providers) ===
GROQ_API_KEY=
OPENAI_API_KEY=
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ frontend/.vite/
# Logs
*.log
logs/
cliproxy-logs/

# CLIProxyAPI OAuth tokens
.cli-proxy-api/

# Temporary
tmp/
Expand Down
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ __pycache__/
.venv/
venv/
htmlcov/
.mypy_cache/
.pytest_cache/
.ruff_cache/
coverage.xml

# IDE
.idea/
Expand All @@ -24,3 +28,5 @@ htmlcov/
*.min.js
*.min.css
coverage/
.playwright-mcp/
preview-*.html
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
"filename": "tests/unit/core/test_transcriber.py",
"hashed_secret": "2e7a7ee14caebf378fc32d6cf6f557f347c96773",
"is_verified": false,
"line_number": 78
"line_number": 82
}
],
"tests/unit/utils/test_config.py": [
Expand All @@ -166,5 +166,5 @@
}
]
},
"generated_at": "2026-02-11T14:21:40Z"
"generated_at": "2026-06-26T09:21:31Z"
}
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,56 @@ docker build -t bilingualsub . && docker run -p 7860:7860 -e GROQ_API_KEY=your_k

Then open http://localhost:7860 in your browser.

### Optional: Docker Compose with CLIProxyAPI

This path is optional. Use it only when you want translations to go through a
local CLIProxyAPI container backed by your own Antigravity/Codex/Claude OAuth
login. The regular Docker flow above does not require CLIProxyAPI.

For Antigravity/agy, CLIProxyAPI can route requests out of the box once the
host has OAuth credentials. Install CLIProxyAPI on the host and log in. This
creates OAuth token files under `~/.cli-proxy-api`, which are mounted read/write
into the proxy container:

```bash
cliproxyapi -antigravity-login
```

Create a local `.env` from the example and set at least `GROQ_API_KEY`:

```bash
cp .env.example .env
```

For the compose setup, use an OpenAI-compatible proxy model:

```env
TRANSLATOR_MODEL=openai:bilingualsub-gemini-flash
# Optional: set only when your auth directory is not ~/.cli-proxy-api
# CLIPROXY_AUTH_DIR=/absolute/path/to/.cli-proxy-api
```

Then start both services:

```bash
docker compose up --build
```

BilingualSub runs at http://localhost:7860. It talks to CLIProxyAPI through the
compose network at `http://cli-proxy:8317/v1`, so OAuth tokens are never baked
into the image or committed to the repository.
The proxy port is bound to `127.0.0.1` only; the compose stack uses the fixed
local bearer key `bilingualsub-local` internally.

The default alias maps to Antigravity's `gemini-3.5-flash-low`, which is the
most consistently discoverable Flash variant in current CLIProxyAPI releases.
If the alias does not exist in your version, list the available proxy models
and set `TRANSLATOR_MODEL=openai:<model-id>` in `.env`:

```bash
curl -H "Authorization: Bearer bilingualsub-local" http://localhost:8317/v1/models
```

### Local Development

**Prerequisites**: Python 3.11+, FFmpeg, Node.js 18+, pnpm
Expand Down Expand Up @@ -56,6 +106,7 @@ Backend runs at http://localhost:8000, frontend at http://localhost:5173.
| `TRANSCRIBER_PROVIDER` | Transcription provider | `groq` | No |
| `TRANSCRIBER_MODEL` | Whisper model to use | `whisper-large-v3-turbo` | No |
| `TRANSLATOR_MODEL` | LLM model for translation | `groq:openai/gpt-oss-120b` | No |
| `OPENAI_BASE_URL` | OpenAI-compatible proxy URL | - | No |

## Architecture

Expand Down
48 changes: 48 additions & 0 deletions README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,53 @@ docker build -t bilingualsub . && docker run -p 7860:7860 -e GROQ_API_KEY=your_k

然後在瀏覽器開啟 http://localhost:7860。

### 選用:使用 CLIProxyAPI 的 Docker Compose

這個流程是選用的。只有當你想讓翻譯走本機 CLIProxyAPI container,並使用
自己的 Antigravity/Codex/Claude OAuth 登入狀態時才需要。上面的單純 Docker
流程不需要 CLIProxyAPI。

對 Antigravity/agy 來說,只要 host 已有 OAuth credentials,CLIProxyAPI 就能
開箱路由請求。先在 host 安裝 CLIProxyAPI 並登入。OAuth token 會建立在
`~/.cli-proxy-api`,之後由 compose 掛進 proxy container:

```bash
cliproxyapi -antigravity-login
```

從範例建立本機 `.env`,並至少設定 `GROQ_API_KEY`:

```bash
cp .env.example .env
```

Compose 模式請使用 OpenAI-compatible proxy model:

```env
TRANSLATOR_MODEL=openai:bilingualsub-gemini-flash
# 選填:只有 auth 目錄不是 ~/.cli-proxy-api 時才需要設定
# CLIPROXY_AUTH_DIR=/absolute/path/to/.cli-proxy-api
```

啟動兩個服務:

```bash
docker compose up --build
```

BilingualSub 會跑在 http://localhost:7860。它會透過 compose network 連到
`http://cli-proxy:8317/v1`,OAuth token 不會被打包進 image,也不會 commit
到 repo。proxy 對 host 只綁定 `127.0.0.1`;compose stack 內部固定使用本機
bearer key `bilingualsub-local`。

預設 alias 對應 Antigravity 的 `gemini-3.5-flash-low`,這是目前 CLIProxyAPI
版本中較穩定可發現的 Flash 變體。如果你的版本沒有這個 alias,可以列出可用
模型,並在 `.env` 設定 `TRANSLATOR_MODEL=openai:<model-id>`:

```bash
curl -H "Authorization: Bearer bilingualsub-local" http://localhost:8317/v1/models
```

### 本地開發

**前置需求**:Python 3.11+、FFmpeg、Node.js 18+、pnpm
Expand Down Expand Up @@ -56,6 +103,7 @@ cd frontend && pnpm dev
| `TRANSCRIBER_PROVIDER` | 語音辨識供應商 | `groq` | 否 |
| `TRANSCRIBER_MODEL` | 使用的 Whisper 模型 | `whisper-large-v3-turbo` | 否 |
| `TRANSLATOR_MODEL` | 翻譯用的 LLM 模型 | `groq:openai/gpt-oss-120b` | 否 |
| `OPENAI_BASE_URL` | OpenAI-compatible proxy URL | - | 否 |

## 架構說明

Expand Down
25 changes: 25 additions & 0 deletions cliproxyapi.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Minimal CLIProxyAPI config for BilingualSub's Docker Compose setup.
# OAuth credentials are discovered from auth-dir, mounted from the host's
# ~/.cli-proxy-api directory by docker-compose.yml.

host: ""
port: 8317
auth-dir: "/root/.cli-proxy-api"

api-keys:
- "bilingualsub-local"

debug: false
logging-to-file: false
usage-statistics-enabled: false

# Optional alias used by docker-compose.yml's default TRANSLATOR_MODEL.
# If CLIProxyAPI changes Antigravity upstream model IDs, set TRANSLATOR_MODEL
# in .env to one of the IDs returned by:
# curl -H "Authorization: Bearer bilingualsub-local" http://localhost:8317/v1/models
oauth-model-alias:
antigravity:
- name: "gemini-3.5-flash-low"
alias: "bilingualsub-gemini-flash"
fork: true
force-mapping: true
27 changes: 27 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
services:
cli-proxy:
image: eceasy/cli-proxy-api:latest
restart: unless-stopped
ports:
- '127.0.0.1:${CLIPROXY_PORT:-8317}:8317'
volumes:
- '${CLIPROXY_AUTH_DIR:-${HOME}/.cli-proxy-api}:/root/.cli-proxy-api'
- './cliproxyapi.conf:/CLIProxyAPI/config.yaml:ro'
command: ['./CLIProxyAPI', '-config', '/CLIProxyAPI/config.yaml']

bilingualsub:
build: .
image: bilingualsub:latest
restart: unless-stopped
ports:
- '${BILINGUALSUB_PORT:-7860}:7860'
environment:
GROQ_API_KEY: '${GROQ_API_KEY:?Set GROQ_API_KEY in .env or your shell}'
GEMINI_API_KEY: '${GEMINI_API_KEY:-}'
TRANSCRIBER_PROVIDER: '${TRANSCRIBER_PROVIDER:-groq}'
TRANSCRIBER_MODEL: '${TRANSCRIBER_MODEL:-whisper-large-v3-turbo}'
TRANSLATOR_MODEL: '${TRANSLATOR_MODEL:-openai:bilingualsub-gemini-flash}'
OPENAI_BASE_URL: 'http://cli-proxy:8317/v1'
OPENAI_API_KEY: 'bilingualsub-local' # pragma: allowlist secret
depends_on:
- cli-proxy
89 changes: 89 additions & 0 deletions frontend/src/components/SubtitleEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { SubtitleEditor } from './SubtitleEditor';

const apiMocks = vi.hoisted(() => ({
fetchSrtContent: vi.fn(),
partialRetranslate: vi.fn(),
addGlossaryEntry: vi.fn(),
}));

const i18nMocks = vi.hoisted(() => ({
t: (key: string) => key,
}));

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: i18nMocks.t,
i18n: { language: 'zh-TW', changeLanguage: vi.fn() },
}),
}));

vi.mock('@/api/client', () => ({
apiClient: {
fetchSrtContent: apiMocks.fetchSrtContent,
partialRetranslate: apiMocks.partialRetranslate,
getDownloadUrl: (jobId: string, fileType: string) => `/api/jobs/${jobId}/download/${fileType}`,
addGlossaryEntry: apiMocks.addGlossaryEntry,
},
}));

const srtContent = `1
00:00:01,000 --> 00:00:02,000
Old translation
old source

2
00:00:03,000 --> 00:00:04,000
Untouched translation
untouched source`;

function mockTextTrack() {
const cues: TextTrackCue[] = [];
return {
cues,
mode: 'hidden',
addCue: vi.fn((cue: TextTrackCue) => cues.push(cue)),
removeCue: vi.fn((cue: TextTrackCue) => {
const index = cues.indexOf(cue);
if (index >= 0) cues.splice(index, 1);
}),
};
}

describe('SubtitleEditor partial retranslate preview', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMocks.fetchSrtContent.mockResolvedValue(srtContent);
HTMLMediaElement.prototype.addTextTrack = vi.fn(() => mockTextTrack() as unknown as TextTrack);
HTMLMediaElement.prototype.play = vi.fn();
globalThis.VTTCue = vi.fn(function VTTCue(startTime, endTime, text) {
return { startTime, endTime, text };
}) as unknown as typeof VTTCue;
});

it('previews and applies corrected source text with translated text', async () => {
apiMocks.partialRetranslate.mockResolvedValue({
results: [{ index: 1, original: 'correct source', translated: 'New translation' }],
});

render(<SubtitleEditor jobId="job-1" onBurn={vi.fn()} isBurning={false} />);

await screen.findByDisplayValue('Old translation');
const retranslateButton = screen.getByRole('button', { name: 'editor.retranslate' });
fireEvent.click(screen.getAllByTitle('editor.selectForRetranslate')[0]);
await waitFor(() => expect(retranslateButton).toBeEnabled());
fireEvent.click(retranslateButton);

await screen.findByText('correct source');
expect(screen.getAllByText('old source')).toHaveLength(2);
expect(screen.getByText('New translation')).toBeInTheDocument();

fireEvent.click(screen.getByText('editor.retranslatePreviewApply'));

await waitFor(() => {
expect(screen.getByDisplayValue('New translation')).toBeInTheDocument();
});
expect(screen.getByText('correct source')).toBeInTheDocument();
expect(screen.queryByText('old source')).not.toBeInTheDocument();
});
});
Loading
Loading