From 85b7e30bf5dcd0576cb251edf56057345578c283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Fri, 24 Apr 2026 14:12:42 +0800 Subject: [PATCH 1/5] feat: add visual description mode for videos without speech New pipeline mode that uses Gemini to analyze video frames and generate translated subtitles from visual content (on-screen text, UI elements, scene descriptions). Users toggle between speech subtitles and visual description via a new UI switch. Backend: core/visual_describer.py (Gemini File API), pipeline branch on processing_mode, configurable model via VISUAL_DESCRIPTION_MODEL env var. Frontend: Toggle in UrlInput, i18n keys, processing_mode in request types. Tests: 7 unit tests + 3 integration tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/arch/visual-description-mode.md | 248 +++++++++++++ docs/design/visual-description-mode.md | 127 +++++++ frontend/src/components/UrlInput.tsx | 41 +++ frontend/src/i18n/en.json | 13 +- frontend/src/i18n/zh-TW.json | 13 +- frontend/src/types.ts | 2 + pyproject.toml | 10 + src/bilingualsub/api/constants.py | 1 + src/bilingualsub/api/jobs.py | 4 + src/bilingualsub/api/pipeline.py | 97 +++++ src/bilingualsub/api/routes.py | 3 + src/bilingualsub/api/schemas.py | 4 +- src/bilingualsub/core/__init__.py | 6 + src/bilingualsub/core/visual_describer.py | 120 +++++++ src/bilingualsub/utils/config.py | 20 ++ .../test_visual_description_pipeline.py | 339 ++++++++++++++++++ tests/unit/core/test_visual_describer.py | 186 ++++++++++ uv.lock | 225 ++++++++++++ 18 files changed, 1452 insertions(+), 7 deletions(-) create mode 100644 docs/arch/visual-description-mode.md create mode 100644 docs/design/visual-description-mode.md create mode 100644 src/bilingualsub/core/visual_describer.py create mode 100644 tests/integration/test_visual_description_pipeline.py create mode 100644 tests/unit/core/test_visual_describer.py diff --git a/docs/arch/visual-description-mode.md b/docs/arch/visual-description-mode.md new file mode 100644 index 0000000..8630446 --- /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 2.5 Flash 直接讀取影片檔(`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 2.5 Flash 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..8b677b7 --- /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 2.5 Flash** → 目前唯一支援原生影片輸入的主流模型,可直接吃整段影片(最長 90 分鐘),同時處理視覺和音訊,不需自行抽 frame。成本低、速度快,適合生產環境。 + +## User Journey + +### Journey 1:觀眾 — 取得品牌影片的視覺描述字幕 + +前置條件:使用者已開啟 BilingualSub 網頁 + +1. 使用者看到 URL 輸入框上方的 Toggle,預設為「語音字幕」模式 +2. 使用者將 Toggle 切換到「視覺描述」模式 + → 頁面提示文字變更,說明此模式會分析畫面內容而非語音 +3. 使用者貼入影片 URL,選擇目標語言,點擊「開始處理」 + → 系統開始下載影片 +4. 下載完成後,系統將影片送入 Gemini 2.5 Flash 分析 + → 進度條顯示「分析畫面內容中...」 +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 2.5 Flash +- **現有 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/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')} + + + + {processingMode === 'visual_description' + ? t('form.processingModeVisual') + : t('form.processingModeSubtitle')} + +
+ {processingMode === 'visual_description' && ( +

+ {t('form.processingModeVisualHint')} +

+ )} +
+