diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2feff72..a7affc2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -42,7 +42,7 @@ repos:
# Mypy - Static type checking
# ===========================================
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.10.0
+ rev: v1.19.1
hooks:
- id: mypy
additional_dependencies:
@@ -53,6 +53,7 @@ repos:
- fastapi>=0.115.0
- structlog>=24.0.0
- sse-starlette>=2.0.0
+ - google-genai>=1.0.0
args: [--config-file=pyproject.toml]
pass_filenames: false
entry: bash -c 'mypy src/'
diff --git a/docs/arch/visual-description-mode.md b/docs/arch/visual-description-mode.md
new file mode 100644
index 0000000..4313c4a
--- /dev/null
+++ b/docs/arch/visual-description-mode.md
@@ -0,0 +1,248 @@
+# Architecture: Visual Description Mode
+
+## 概述
+
+在現有 download → subtitle → burn 三階段管線上,新增一條平行的字幕生成路徑:當使用者選擇「視覺描述」模式時,subtitle phase 以 `describe_video()` 取代 `transcribe_audio()`。Gemini 3.1 Flash Lite Preview 直接讀取影片檔(`FileType.SOURCE_VIDEO`)並回傳帶時間戳的畫面描述,再由現有 `translate_subtitle()` 翻譯成目標語言。因為視覺描述不存在「原文字幕」概念,merge 步驟跳過,只序列化目標語言 SRT。
+
+## Files to Create / Modify
+
+### 新建
+
+| 路徑 | 說明 |
+| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| `src/bilingualsub/core/visual_describer.py` | Gemini File API 封裝;對外唯一函數 `describe_video(video_path, *, source_lang) -> Subtitle` |
+| `tests/unit/core/test_visual_describer.py` | UT:mock `google.genai.Client`,驗證解析邏輯與錯誤路徑 |
+| `tests/integration/test_visual_description_pipeline.py` | IT:Journey 1 端到端鏈(POST /jobs → POST /jobs/:id/subtitle 含 processing_mode → validate SRT exists) |
+
+### 修改
+
+| 路徑 | 修改內容 |
+| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/bilingualsub/api/constants.py` | `SubtitleSource` 新增 `VISUAL_DESCRIPTION = "visual_description"` |
+| `src/bilingualsub/api/jobs.py` | `Job` dataclass 新增 `processing_mode: str = "subtitle"` 和 `video_duration: float = 0.0`;`JobManager.create_job()` 新增 `processing_mode` 參數 |
+| `src/bilingualsub/api/schemas.py` | `JobCreateRequest` 新增 `processing_mode: Literal["subtitle", "visual_description"] = "subtitle"`;`StartSubtitleRequest` 新增同欄位 |
+| `src/bilingualsub/api/routes.py` | `create_job()` 傳 `processing_mode`;`start_subtitle()` 覆寫 `job.processing_mode` |
+| `src/bilingualsub/api/pipeline.py` | `run_subtitle()` 依 `job.processing_mode` 分支;新增 `_run_visual_description_subtitle()` 和 `_serialize_translated_only()`;`_ERROR_MAP` 加 `VisualDescriptionError` |
+| `src/bilingualsub/core/__init__.py` | 匯出 `VisualDescriptionError`, `describe_video` |
+| `src/bilingualsub/utils/config.py` | `Settings` 新增 `gemini_api_key: str = ""`;新增 `get_gemini_api_key()` guard function |
+| `pyproject.toml` | `dependencies` 加 `google-genai>=1.0.0`;mypy override 加 `google.genai.*` |
+| `frontend/src/types.ts` | `JobCreateRequest` 新增 `processing_mode?: 'subtitle' \| 'visual_description'` |
+| `frontend/src/components/UrlInput.tsx` | 新增 `processingMode` state 與 Toggle UI(參考 rangeEnabled 模式) |
+| `frontend/src/i18n/zh-TW.json` | `form` 加 Toggle 相關 key;`progress` 加 `describing`;`error` 加 `visual_description_failed` |
+| `frontend/src/i18n/en.json` | 同上英文 key |
+
+## Responsibility Map
+
+| 元件 | 層級 | 負責 | 不碰 |
+| -------------------------------------------------------- | ---------- | ------------------------------------------------------------------- | ----------------------------- |
+| `core/visual_describer.py` | Core | Gemini API 呼叫、response 解析、timestamp regex、回傳 `Subtitle` | pipeline 進度、job 狀態、翻譯 |
+| `api/pipeline.py` — `_run_visual_description_subtitle()` | Pipeline | 進度管理、呼叫 describe_video + translate、影片時長驗證、SRT 序列化 | Gemini API 細節、前端狀態 |
+| `api/pipeline.py` — `_serialize_translated_only()` | Pipeline | 單語 SRT 序列化、寫入 output_files | 翻譯邏輯、merge 邏輯 |
+| `api/routes.py` | Controller | schema 驗證、`processing_mode` 傳遞給 Job 和 pipeline | pipeline 邏輯、Gemini 細節 |
+| `api/schemas.py` | Schema | request 驗證(`Literal["subtitle", "visual_description"]`) | 業務邏輯 |
+| `frontend/UrlInput.tsx` | View | Toggle 渲染、`processing_mode` 附加到 request | API 呼叫、狀態管理 |
+
+## Interface Design
+
+### `describe_video` 函數簽名
+
+```python
+def describe_video(
+ video_path: Path,
+ *,
+ source_lang: str = "en",
+) -> Subtitle:
+ """Analyze video frames with Gemini 3.1 Flash Lite Preview and return timestamped descriptions.
+
+ Raises:
+ VisualDescriptionError: If Gemini API fails or no segments can be parsed.
+ ValueError: If GEMINI_API_KEY is not set or video_path doesn't exist.
+ """
+```
+
+### `VisualDescriptionError`
+
+```python
+class VisualDescriptionError(Exception):
+ """Raised when Gemini visual description fails."""
+```
+
+### `Settings` 新增欄位
+
+```python
+gemini_api_key: str = ""
+```
+
+### `get_gemini_api_key()`
+
+```python
+def get_gemini_api_key() -> str:
+ settings = get_settings()
+ if not settings.gemini_api_key:
+ raise ValueError(
+ "GEMINI_API_KEY environment variable is not set. "
+ "Please set it with your Gemini API key."
+ )
+ return settings.gemini_api_key
+```
+
+### `JobCreateRequest` 更新
+
+```python
+processing_mode: Literal["subtitle", "visual_description"] = "subtitle"
+```
+
+### `StartSubtitleRequest` 更新
+
+```python
+processing_mode: Literal["subtitle", "visual_description"] | None = None
+```
+
+### 前端 `JobCreateRequest` 更新
+
+```typescript
+processing_mode?: 'subtitle' | 'visual_description';
+```
+
+## Data Flow
+
+### 視覺描述路徑(Journey 1)
+
+```
+使用者切換 Toggle → processing_mode: "visual_description"
+ │
+POST /api/jobs { source_url, processing_mode: "visual_description" }
+ │
+ JobManager.create_job(processing_mode="visual_description")
+ │
+ run_download(job)
+ ├── _acquire_video() → job.output_files[SOURCE_VIDEO], job.video_duration
+ ├── _extract_audio_step() ← 仍執行(架構簡單,多幾秒無害)
+ └── _send_download_complete()
+ │
+前端 download_complete → 使用者點「產生字幕」
+ │
+POST /api/jobs/:id/subtitle { processing_mode: "visual_description" }
+ │
+ routes.start_subtitle() → job.processing_mode = "visual_description"
+ │
+ run_subtitle(job) → job.processing_mode == "visual_description"
+ │
+ _run_visual_description_subtitle(job)
+ ├── validate video_duration <= 5400 (90 min)
+ ├── progress 20% "describe" — "分析畫面內容中..."
+ ├── describe_video(SOURCE_VIDEO, source_lang=job.source_lang) → Subtitle
+ │ └── google-genai: files.upload → models.generate_content → parse timestamps
+ ├── job.subtitle_source = VISUAL_DESCRIPTION
+ ├── progress 50% "translate"
+ ├── translate_subtitle(described_sub, ...) → translated_sub
+ ├── progress 70% "serialize"
+ ├── _serialize_translated_only(translated_sub)
+ │ └── serialize_srt → subtitle.srt → job.output_files[SRT]
+ └── _send_complete(job)
+ │
+前端 completed → SubtitleEditor 載入 SRT(單語,只有 translated 欄位)
+ │
+POST /api/jobs/:id/burn { srt_content } → run_burn()(完全複用)
+```
+
+### 語音字幕路徑(不受影響)
+
+`job.processing_mode == "subtitle"` → 現有 `run_subtitle()` 主體邏輯不變。
+
+## Build Sequence
+
+### Phase 1:後端基礎(additive)
+
+- `pyproject.toml`:加 `google-genai>=1.0.0` 依賴;加 mypy override
+- `utils/config.py`:加 `gemini_api_key` 欄位、`get_gemini_api_key()` 函數
+- `api/constants.py`:`SubtitleSource` 加 `VISUAL_DESCRIPTION`
+- `api/jobs.py`:`Job` 加 `processing_mode`, `video_duration`;`JobManager.create_job()` 加 `processing_mode` 參數
+- `api/pipeline.py`:`run_download()` 補存 `job.video_duration`
+
+### Phase 2:Core 模組(additive)
+
+- `core/visual_describer.py`:實作 `describe_video()`,含 `DESCRIBE_PROMPT`、timestamp regex parser、`VisualDescriptionError`
+- `core/__init__.py`:匯出新符號
+
+### Phase 3:Pipeline 分支(breaking — run_subtitle 需同步改動)
+
+- `api/pipeline.py`:`_run_visual_description_subtitle()`;`_serialize_translated_only()`;`run_subtitle()` 加分支;`_ERROR_MAP` 加 `VisualDescriptionError`
+- `api/schemas.py`:`JobCreateRequest` 加 `processing_mode`;`StartSubtitleRequest` 加 `processing_mode`
+- `api/routes.py`:`create_job()` 傳 `processing_mode`;`start_subtitle()` 覆寫 `job.processing_mode`
+
+### Phase 4:前端(additive)
+
+- `frontend/src/types.ts`:`JobCreateRequest` 加 `processing_mode`
+- `frontend/src/i18n/zh-TW.json` & `en.json`:加新 i18n key
+- `frontend/src/components/UrlInput.tsx`:加 `processingMode` state 與 Toggle UI
+
+### Phase 5:測試(additive)
+
+- `tests/unit/core/test_visual_describer.py`
+- `tests/integration/test_visual_description_pipeline.py`
+
+## Infra Reuse
+
+| 現有元件 | 視覺描述路徑如何複用 |
+| ------------------------------- | --------------------------------------------------------------------- |
+| `run_download()` | 完全複用,`SOURCE_VIDEO` 已存於 `output_files`,補存 `video_duration` |
+| `translate_subtitle()` | 完全複用,`described_sub` 與 `original_sub` 型別相同(`Subtitle`) |
+| `serialize_srt()` | 複用,只呼叫一次(翻譯後字幕) |
+| `run_burn()` | 完全複用,接受 SRT 字串即可,不感知生成路徑 |
+| `SubtitleEditor` | 複用,`original` 欄位在視覺描述模式下留空 |
+| `_make_translate_progress_cb()` | 完全複用,仍映射 50-70% |
+
+## Test Strategy
+
+### Unit Test 邊界
+
+**`tests/unit/core/test_visual_describer.py`**
+
+| 目標 | 測試行為 |
+| ---------------- | ------------------------------------------------------------------------------ |
+| `describe_video` | 有效 Gemini response(3 條 MM:SS 時間戳)→ `Subtitle` with 3 entries,時間正確 |
+| `describe_video` | response 無法解析出任何 entry → 拋 `VisualDescriptionError` |
+| `describe_video` | `generate_content` 拋 exception → 包裝成 `VisualDescriptionError` |
+| `describe_video` | `GEMINI_API_KEY` 未設 → `ValueError` |
+| `describe_video` | 傳入不存在的 `video_path` → `ValueError` |
+| `describe_video` | response 中混有不符格式的行 → 只保留可解析的 entries,不拋錯 |
+
+**`tests/unit/api/` — pipeline 視覺描述分支**
+
+| 目標 | 測試行為 |
+| ---------------------------------- | ----------------------------------------------------------------- |
+| `_run_visual_description_subtitle` | `job.video_duration = 5401.0` → `PipelineError("video_too_long")` |
+| `_serialize_translated_only` | 只寫 `FileType.SRT`,`FileType.ASS` 不在 output_files |
+
+### Integration Test 邊界
+
+**`tests/integration/test_visual_description_pipeline.py`**
+
+| Journey 步驟 | Test Chain |
+| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| 使用者選視覺描述 → 產出 SRT | POST /jobs (processing_mode=visual_description) → inject DOWNLOAD_COMPLETE state → POST /subtitle → poll until COMPLETED → GET /download/srt → 200, SRT 非空, ASS 不存在 |
+| 影片超過 90 分鐘 → 失敗 | POST /jobs → inject video_duration=5401 → POST /subtitle → poll → status=failed, error_code="video_too_long" |
+| 缺 GEMINI_API_KEY → 失敗 | monkeypatch.delenv GEMINI_API_KEY → POST /subtitle → status=failed |
+
+### Mock 決策
+
+| 對象 | Mock / Real | 原因 |
+| ------------------------- | ---------------------- | ------------------------------------------- |
+| `google.genai.Client` | Mock | 外部 API,不穩定且需付費 |
+| `translate_subtitle` (IT) | Mock | 避免呼叫 Groq/OpenAI,回傳固定 `Subtitle` |
+| `describe_video` (IT) | Mock | 避免呼叫 Gemini,但驗證其輸出能正確流入下游 |
+| `get_settings` | Real + monkeypatch env | 驗證 env 讀取邏輯正確 |
+
+### Coverage 要求
+
+- `core/visual_describer.py` ≥ 80%
+- `api/pipeline.py` 視覺描述分支被 IT 覆蓋
+- 整體 ≥ 80%
+
+## 開放問題
+
+1. **Gemini 時間戳格式**:實際輸出格式(`MM:SS` vs `HH:MM:SS`)需 API 測試確認。timestamp regex 應寬鬆設計,覆蓋兩種格式。
+2. **audio extraction 是否跳過**:視覺描述不需要音訊,但 `run_download()` 不感知 `processing_mode`。選擇維持現狀(多幾秒但架構簡單)。
+3. **SubtitleEditor 對空 `original` 的處理**:需確認渲染邏輯對空字串的容忍度(可能顯示單行而非雙行)。
+4. **Gemini 上傳檔案清理**:`client.files.upload()` 上傳的檔案預設 TTL 48 小時。第一版不主動清理。
diff --git a/docs/design/visual-description-mode.md b/docs/design/visual-description-mode.md
new file mode 100644
index 0000000..0657d5a
--- /dev/null
+++ b/docs/design/visual-description-mode.md
@@ -0,0 +1,127 @@
+# 視覺描述模式(Visual Description Mode)
+
+## 背景與問題
+
+現有系統僅支援語音轉字幕(Whisper ASR → LLM 翻譯),遇到無語音的影片(品牌形象影片、純音樂 MV、產品展示動畫等)時,Whisper 回傳空 segments 直接拋出 `TranscriptionError`,使用者只會看到一個模糊的「轉錄失敗」錯誤訊息。
+
+這類影片的畫面上往往有大量有價值的視覺資訊——標題文字、產品名稱、UI 介面文字、場景說明——但系統完全無法處理,使用者只能放棄。
+
+不做的後果:整個工具的適用範圍被限縮在「有人說話的影片」,大量品牌內容、教學動畫、產品 Demo 無法使用。
+
+## 使用者角色
+
+**一般觀眾**:想理解外語品牌影片或產品介紹的內容,貼入 URL 後期望系統能產出翻譯後的說明字幕。
+
+## 需求情境
+
+- 一般觀眾:When 我看到一支外語品牌形象影片,畫面上有文字但沒有旁白,I want to 讓系統分析畫面內容並翻譯成我的語言,so I can 理解影片在傳達什麼。
+
+## 設計意圖
+
+- **手動切換而非自動偵測** → 自動偵測需要先跑 Whisper 才能判斷有無語音,浪費時間且判斷邊界模糊(幾句話算「有語音」?)。手動切換讓使用者掌控意圖,流程更直覺。
+- **只產出翻譯後的單語字幕** → 視覺描述的「原文」是畫面內容而非語言文字,雙語對照在此場景沒有意義。
+- **第一版不做混合模式** → 混合模式需要時間軸對齊和內容類型判斷,複雜度高。先做純模式,驗證價值後再擴展。
+- **使用 Gemini 3.1 Flash Lite Preview** → 目前唯一支援原生影片輸入的主流模型,可直接吃整段影片(最長 90 分鐘),同時處理視覺和音訊,不需自行抽 frame。成本低、速度快,適合生產環境。
+
+## User Journey
+
+### Journey 1:觀眾 — 取得品牌影片的視覺描述字幕
+
+前置條件:使用者已開啟 BilingualSub 網頁
+
+1. 使用者看到 URL 輸入框上方的 Toggle,預設為「語音字幕」模式
+2. 使用者將 Toggle 切換到「視覺描述」模式
+ → 頁面提示文字變更,說明此模式會分析畫面內容而非語音
+3. 使用者貼入影片 URL,選擇目標語言,點擊「開始處理」
+ → 系統開始下載影片
+4. 下載完成後,系統將影片送入 Gemini 3.1 Flash Lite Preview 分析
+ → 進度條顯示「分析畫面內容中...」
+5. Gemini 回傳帶時間戳的畫面描述(英文或原始語言)
+ → 系統將描述翻譯成目標語言
+6. 翻譯完成,使用者看到字幕預覽
+ → 字幕以時間軸格式顯示,每條字幕對應一個畫面片段
+7. 使用者可選擇「下載字幕檔」(SRT)或「燒錄進影片」
+ → 與現有語音字幕流程一致的輸出選項
+
+### Journey 2:觀眾 — 切換回語音字幕模式
+
+前置條件:使用者目前在「視覺描述」模式
+
+1. 使用者將 Toggle 切回「語音字幕」
+ → 回到原有的語音字幕流程,所有現有功能不受影響
+
+## 替代流程
+
+- **影片過長(超過 90 分鐘)**:系統提示「影片過長,視覺描述模式最長支援 90 分鐘」,建議使用者裁剪影片或使用時間範圍功能
+- **Gemini 回傳內容極少**:影片畫面資訊不足(如純黑畫面、靜態圖片),系統仍產出結果但字幕數量可能很少,不額外提示
+
+## 錯誤情境
+
+### 系統錯誤
+
+- Gemini API 呼叫失敗(網路、quota、API key 無效):顯示明確錯誤訊息「視覺分析服務暫時無法使用,請稍後再試」
+- 影片下載失敗:與現有語音模式共用相同的下載錯誤處理
+
+### 使用者誤操作
+
+- 對有大量語音的影片使用視覺描述模式:系統正常執行,只是產出的字幕是畫面描述而非語音轉錄。不阻擋,因為使用者可能確實想要畫面描述
+- 未設定 Gemini API key 就使用視覺描述模式:啟動時檢查,提示「請設定 GEMINI_API_KEY 環境變數」
+
+### 惡意行為
+
+- 不適用(無額外攻擊面,影片下載的安全性由現有 yt-dlp 處理)
+
+## Out of Scope
+
+- 語音字幕 + 視覺描述混合模式
+- 自動偵測影片有無語音並切換模式
+- 雙語對照輸出(原文描述 + 翻譯)
+- 自訂 Gemini prompt / 描述風格
+- 支援 Gemini 以外的視覺模型
+
+## 整合點
+
+- **Gemini API**:新增 `GEMINI_API_KEY` 環境變數,透過 Google AI SDK 呼叫 Gemini 3.1 Flash Lite Preview
+- **現有 Pipeline**:視覺描述模式複用現有的 download → translate → merge → burn 步驟,僅將 transcribe 步驟替換為 Gemini 視覺分析
+- **前端狀態**:`useJob` hook 需支援新的模式參數,Toggle 狀態影響 API 請求的 payload
+- **翻譯模組**:視覺描述的翻譯複用現有的 translator,輸入格式與語音轉錄的字幕條目相同
+
+## Acceptance Criteria
+
+- Given 使用者在首頁
+ When 頁面載入
+ Then 看到 Toggle 預設為「語音字幕」模式
+
+- Given 使用者切換到「視覺描述」模式
+ When 貼入影片 URL 並點擊開始
+ Then 系統使用 Gemini 分析畫面內容,而非 Whisper 語音辨識
+
+- Given 視覺分析完成
+ When 使用者查看結果
+ Then 看到帶時間戳的翻譯後字幕,內容描述畫面中的文字和視覺元素
+
+- Given 視覺描述字幕產出完成
+ When 使用者選擇「燒錄進影片」
+ Then 字幕被燒錄進影片,與語音字幕的燒錄效果一致
+
+- Given 視覺描述字幕產出完成
+ When 使用者選擇「下載字幕檔」
+ Then 下載到 SRT 格式的字幕檔
+
+- Given 使用者切換回「語音字幕」模式
+ When 操作流程
+ Then 所有現有功能不受影響,行為與切換前完全一致
+
+- Given 未設定 GEMINI_API_KEY
+ When 使用者嘗試使用視覺描述模式
+ Then 顯示明確提示要求設定 API key
+
+- Given 影片超過 90 分鐘
+ When 使用者以視覺描述模式處理
+ Then 顯示影片過長的提示訊息
+
+## 開放問題
+
+- Gemini 回傳的時間戳精度是否足夠產出流暢的字幕體驗?需實際測試驗證
+- 視覺描述的翻譯品質是否需要針對描述性文本調整 prompt?與語音轉錄的翻譯 prompt 可能有差異
+- 是否需要讓使用者指定「原始語言」?Gemini 可能需要知道畫面上文字的語言才能更準確辨識
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 81cc405..6822d5d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -120,7 +120,9 @@ function App() {
{
- const payload: { source_lang?: string; target_lang?: string } = {};
+ const payload: { source_lang?: string; target_lang?: string; processing_mode?: string } = {};
if (sourceLang) payload.source_lang = sourceLang;
if (targetLang) payload.target_lang = targetLang;
+ if (processingMode) payload.processing_mode = processingMode;
const response = await fetch(`${this.baseUrl}/api/jobs/${jobId}/subtitle`, {
method: 'POST',
diff --git a/frontend/src/components/DownloadLinks.tsx b/frontend/src/components/DownloadLinks.tsx
index 23db990..b4ea8f2 100644
--- a/frontend/src/components/DownloadLinks.tsx
+++ b/frontend/src/components/DownloadLinks.tsx
@@ -4,10 +4,12 @@ import { FileType } from '../constants';
import { apiClient } from '../api/client';
import { DisclaimerDialog } from './DisclaimerDialog';
import { triggerDownload } from '../utils/download';
+import type { ProcessingMode } from '../types';
interface DownloadLinksProps {
jobId: string;
showVideo?: boolean;
+ processingMode?: ProcessingMode | null;
}
const FILE_OPTIONS = [
@@ -17,12 +19,20 @@ const FILE_OPTIONS = [
{ type: FileType.AUDIO, labelKey: 'download.audio' },
] as const;
-export function DownloadLinks({ jobId, showVideo }: DownloadLinksProps) {
+export function DownloadLinks({ jobId, showVideo, processingMode }: DownloadLinksProps) {
const { t } = useTranslation();
const [pendingUrl, setPendingUrl] = useState(null);
- const visibleOptions =
- showVideo === false ? FILE_OPTIONS.filter(opt => opt.type !== FileType.VIDEO) : FILE_OPTIONS;
+ let visibleOptions =
+ showVideo === false
+ ? FILE_OPTIONS.filter(opt => opt.type !== FileType.VIDEO)
+ : [...FILE_OPTIONS];
+
+ if (processingMode === 'visual_description') {
+ visibleOptions = visibleOptions.filter(
+ opt => opt.type !== FileType.ASS && opt.type !== FileType.AUDIO
+ );
+ }
return (
<>
diff --git a/frontend/src/components/ProgressTracker.tsx b/frontend/src/components/ProgressTracker.tsx
index 4a41fbe..c8f4e85 100644
--- a/frontend/src/components/ProgressTracker.tsx
+++ b/frontend/src/components/ProgressTracker.tsx
@@ -59,7 +59,9 @@ export function ProgressTracker({
{': '}
{subtitleSource === SubtitleSource.YOUTUBE_MANUAL
? t('progress.subtitleSourceYoutube')
- : t('progress.subtitleSourceWhisper')}
+ : subtitleSource === SubtitleSource.VISUAL_DESCRIPTION
+ ? t('progress.subtitleSourceVisual')
+ : t('progress.subtitleSourceWhisper')}
)}
diff --git a/frontend/src/components/UrlInput.tsx b/frontend/src/components/UrlInput.tsx
index 6ac15f6..b1fefb1 100644
--- a/frontend/src/components/UrlInput.tsx
+++ b/frontend/src/components/UrlInput.tsx
@@ -28,6 +28,9 @@ export function UrlInput({ onSubmit, disabled }: UrlInputProps) {
const [url, setUrl] = useState('');
const [selectedFile, setSelectedFile] = useState(null);
const fileInputRef = useRef(null);
+ const [processingMode, setProcessingMode] = useState<'subtitle' | 'visual_description'>(
+ 'subtitle'
+ );
const [rangeEnabled, setRangeEnabled] = useState(false);
const [startTime, setStartTime] = useState({
hours: '00',
@@ -65,6 +68,7 @@ export function UrlInput({ onSubmit, disabled }: UrlInputProps) {
}
const request: JobUploadRequest = {
file: selectedFile,
+ processing_mode: processingMode,
};
if (startSeconds !== undefined) request.start_time = startSeconds;
if (endSeconds !== undefined) request.end_time = endSeconds;
@@ -80,6 +84,7 @@ export function UrlInput({ onSubmit, disabled }: UrlInputProps) {
const request: JobCreateRequest = {
source_url: url,
+ processing_mode: processingMode,
};
if (startSeconds !== undefined) request.start_time = startSeconds;
if (endSeconds !== undefined) request.end_time = endSeconds;
@@ -153,6 +158,42 @@ export function UrlInput({ onSubmit, disabled }: UrlInputProps) {
)}
+
+
+
+ {t('form.processingModeLabel')}
+
+
+ setProcessingMode(prev => (prev === 'subtitle' ? 'visual_description' : 'subtitle'))
+ }
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
+ processingMode === 'visual_description'
+ ? 'bg-blue-600'
+ : 'bg-gray-300 dark:bg-gray-600'
+ }`}
+ >
+
+
+
+ {processingMode === 'visual_description'
+ ? t('form.processingModeVisual')
+ : t('form.processingModeSubtitle')}
+
+
+ {processingMode === 'visual_description' && (
+
+ {t('form.processingModeVisualHint')}
+
+ )}
+
+