From f3cab0929a3e2b9b01fdff5726c91f7eac82fe84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Mon, 20 Apr 2026 21:16:06 +0800 Subject: [PATCH 1/3] feat: add glossary term preservation, smart subtitle source, and Vite 8 upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade Vite 7→8 (Rolldown engine) with plugin-react 6 - Add GlossaryManager with JSON persistence, atomic save, thread-safe writes, and prompt caching for term preservation during translation - Add YouTube manual subtitle download via yt-dlp with Whisper fallback - Add SubtitleSource enum to eliminate cross-layer magic strings - Add glossary CRUD API (GET/POST/PUT/DELETE /api/glossary) - Add GlossaryPanel frontend component with optimistic state updates - Send subtitle_source via SSE for frontend display - Extract _merge_and_serialize helper to reduce pipeline complexity - Include VTT-to-SRT conversion utility for downloaded subtitles - Add 28 new unit tests covering glossary, subtitle fetcher, and API routes Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/design/v2-smart-subtitle-pipeline.md | 183 +++++ frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 697 ++++++++++++++++---- frontend/src/App.tsx | 3 + frontend/src/api/client.ts | 44 ++ frontend/src/components/GlossaryPanel.tsx | 245 +++++++ frontend/src/components/ProgressTracker.tsx | 15 +- frontend/src/components/SubtitleEditor.tsx | 16 + frontend/src/constants.ts | 6 + frontend/src/hooks/useJob.ts | 3 + frontend/src/i18n/en.json | 21 +- frontend/src/i18n/zh-TW.json | 21 +- frontend/src/types.ts | 10 + src/bilingualsub/api/app.py | 4 + src/bilingualsub/api/constants.py | 7 + src/bilingualsub/api/jobs.py | 2 + src/bilingualsub/api/pipeline.py | 153 +++-- src/bilingualsub/api/routes.py | 72 +- src/bilingualsub/api/schemas.py | 15 + src/bilingualsub/core/__init__.py | 7 + src/bilingualsub/core/glossary.py | 134 ++++ src/bilingualsub/core/subtitle_fetcher.py | 142 ++++ src/bilingualsub/core/translator.py | 22 +- src/bilingualsub/utils/config.py | 1 + tests/unit/api/test_routes.py | 70 +- tests/unit/core/test_glossary.py | 121 ++++ tests/unit/core/test_subtitle_fetcher.py | 57 ++ 27 files changed, 1887 insertions(+), 188 deletions(-) create mode 100644 docs/design/v2-smart-subtitle-pipeline.md create mode 100644 frontend/src/components/GlossaryPanel.tsx create mode 100644 src/bilingualsub/core/glossary.py create mode 100644 src/bilingualsub/core/subtitle_fetcher.py create mode 100644 tests/unit/core/test_glossary.py create mode 100644 tests/unit/core/test_subtitle_fetcher.py diff --git a/docs/design/v2-smart-subtitle-pipeline.md b/docs/design/v2-smart-subtitle-pipeline.md new file mode 100644 index 0000000..0e347b1 --- /dev/null +++ b/docs/design/v2-smart-subtitle-pipeline.md @@ -0,0 +1,183 @@ +# V2: 智慧字幕管線升級 + +## 背景與問題 + +BilingualSub 目前的管線是「一條路」:所有影片都經過 Whisper 轉錄 → LLM 翻譯 → 合併。這帶來三個痛點: + +1. **時間戳偏移**:Groq Whisper 對非 0 秒起始的音訊會把時間戳歸零(如音訊 03 秒開始 → 轉錄判定為 00 秒),這是 Whisper 架構的已知限制 +2. **專有名詞被亂翻**:技術詞彙如 Agent、Skills、Claude Code 被翻成「代理人」「技能」,翻譯 prompt 沒有術語保留機制 +3. **無語音影片無法處理**:螢幕錄製、操作教學等沒有旁白的影片完全無法產生字幕 + +另外前端 Vite 7 需要升級到 Vite 8。 + +## 使用者角色 + +使用者:需要將外語 YouTube 影片加上雙語字幕的個人使用者。痛點是翻譯品質不穩定、某些影片根本無法處理。 + +## 需求情境 + +- 使用者:When 影片有 YouTube 手動上傳字幕時,I want to 直接使用那些字幕來翻譯,so I can 避免 Whisper 的時間戳偏移問題且獲得更準確的原文 +- 使用者:When 翻譯結果把專有名詞翻錯時,I want to 建立一組術語表讓系統記住,so I can 不用每次都手動修正相同的錯誤 +- 使用者:When 影片沒有語音時,I want to 系統自動辨識畫面內容並產生描述性字幕,so I can 為操作教學等影片加上字幕 + +## 設計意圖 + +- **優先 YouTube 手動字幕、不用自動字幕** → 研究顯示 YouTube 自動字幕準確率僅 60-70%,Whisper 有 95%+。手動上傳字幕通常最準確,但自動生成的比 Whisper 差,所以只下載手動字幕 +- **Glossary 用 JSON 而非 SQLite** → 專案目前無資料庫(in-memory job store),JSON 和現有架構一致,百筆術語綽綽有餘 +- **Vision 模型不限定 provider** → 遵循現有 translator 用 Agno 抽象的模式,由環境變數切換 +- **靜音偵測自動分流** → 使用者不需要手動選擇管線模式,系統自動判斷 + +## User Journey + +### Journey 1:使用者 — 有手動字幕的影片 + +前置條件:影片在 YouTube 上有創作者上傳的字幕 + +1. 使用者貼上 YouTube URL → 系統開始下載影片 +2. 系統用 yt-dlp 檢查是否有手動上傳字幕 → 發現有英文手動字幕 +3. 進度顯示「使用 YouTube 字幕」→ 系統下載 SRT 字幕並解析 +4. 跳過 Whisper 轉錄 → 直接進入翻譯步驟 +5. 翻譯完成 → 使用者在編輯器中檢視雙語字幕 + → 時間戳來自 YouTube 原始字幕,精準度高 + +### Journey 2:使用者 — 無手動字幕的影片(有語音) + +前置條件:影片沒有手動字幕但有語音 + +1. 使用者貼上 YouTube URL → 系統下載影片 +2. 系統檢查手動字幕 → 無可用字幕 +3. 進度顯示「正在轉錄(Whisper)」→ 走既有 Whisper 管線 +4. 後續流程不變(翻譯 → 合併 → 燒錄) + +### Journey 3:使用者 — 管理術語表 + +前置條件:使用者翻譯過影片,發現某些專有名詞被翻錯 + +1. 使用者在字幕編輯器中看到 "Agent" 被翻成「代理人」 +2. 使用者點選該字幕條目的「加入術語表」按鈕 +3. 彈出輸入框:原文 "Agent"、目標 "Agent"(保留原文)→ 確認 +4. 術語被儲存到 glossary.json → 下次翻譯時 LLM prompt 會包含此術語 +5. 使用者也可以從工具列打開「術語表管理」面板,批次新增/編輯/刪除術語 + +### Journey 4:使用者 — 術語表生效 + +前置條件:術語表中已有 "Agent → Agent"、"Skills → Skills" + +1. 使用者提交新影片翻譯 +2. 系統載入 glossary.json,將術語表注入翻譯 prompt +3. LLM 翻譯時保留 "Agent"、"Skills" 不翻譯 +4. 使用者檢視結果 → 專有名詞正確保留 + +### Journey 5:使用者 — 無語音影片(視覺描述) + +前置條件:影片是螢幕錄製或操作教學,沒有旁白 + +1. 使用者貼上影片 URL → 系統下載影片 +2. 系統擷取音訊 → 偵測到幾乎全靜音 +3. 進度顯示「偵測到無語音影片,正在分析畫面」 +4. 系統每 5 秒擷取一個關鍵幀 → 送到 vision 模型分析 +5. Vision 模型產生描述(如「打開設定頁面,點擊帳號選項」) +6. 描述轉換為字幕格式 → 進入翻譯 → 合併為雙語字幕 + +## 替代流程 + +- **yt-dlp 字幕下載失敗**:記錄 warning,靜默 fallback 到 Whisper 轉錄 +- **靜音偵測誤判**(有語音但判為靜音):使用者可在前端手動觸發重新轉錄(未來可加 force_transcribe 參數) +- **Vision 模型不可用**:回傳錯誤提示「此影片無語音且未設定視覺模型,請設定 VISION_MODEL 環境變數」 +- **Glossary 檔案損壞/不存在**:系統啟動時建立空 glossary,損壞時重建並記錄 warning + +## 錯誤情境 + +### 系統錯誤 + +- yt-dlp 字幕 API 回傳格式異常 → 解析失敗後 fallback Whisper +- Vision 模型 API 超時/額度不足 → 回傳明確錯誤碼 +- glossary.json 寫入失敗(磁碟滿)→ 回傳 500 錯誤 + +### 使用者誤操作 + +- 在術語表中新增空白原文 → 前後端驗證,拒絕空值 +- 在術語表中新增重複原文 → 更新既有條目而非新增 + +### 惡意行為 + +- Glossary 注入(用極長文字或 prompt injection 內容作為術語)→ 限制單一術語長度 100 字元,glossary 總量上限 500 筆 + +## Out of Scope + +- 多使用者 glossary 隔離(目前是單一 glossary 全域共用) +- Glossary 分類標籤或群組 +- WhisperX 本地端對齊(需要 GPU,架構差異太大) +- YouTube 自動生成字幕的使用(研究顯示品質不如 Whisper) +- 即時串流字幕 +- Vite 8 升級細節(純基礎設施變更,不影響功能設計) + +## 整合點 + +| 系統 | 用途 | 備註 | +| ------------------- | ------------------------------------------- | ------------------------------- | +| yt-dlp | 影片下載 + 字幕下載 | 新增 `writesubtitles` 選項 | +| Groq Whisper API | 語音轉錄 | 既有,作為 fallback | +| Agno + LLM | 翻譯 | 既有,新增 glossary prompt 注入 | +| Agno + Vision Model | 畫面描述 | 新增,provider TBD | +| FFmpeg | 音訊擷取 + 靜音偵測 + 關鍵幀擷取 + 字幕燒錄 | 新增靜音偵測和幀擷取 | +| JSON 檔案系統 | Glossary 持久化 | 新增 | + +## Acceptance Criteria + +### 字幕來源 + +- Given 影片有 YouTube 手動上傳的英文字幕 + When 使用者提交該影片進行翻譯 + Then 系統下載手動字幕並跳過 Whisper 轉錄,進度顯示「使用 YouTube 字幕」 + +- Given 影片只有 YouTube 自動生成字幕(無手動字幕) + When 使用者提交該影片進行翻譯 + Then 系統使用 Whisper 轉錄,不使用自動生成字幕 + +- Given 影片既無手動也無自動字幕 + When 使用者提交該影片進行翻譯 + Then 系統使用 Whisper 轉錄 + +- Given yt-dlp 字幕下載過程中發生錯誤 + When 系統嘗試取得字幕 + Then 自動 fallback 到 Whisper 轉錄,不中斷流程 + +### 術語表 + +- Given 術語表中有 "Agent → Agent" + When 翻譯包含 "Agent" 的字幕 + Then 翻譯結果保留 "Agent" 不翻譯 + +- Given 使用者在字幕編輯器中選擇一個條目 + When 點擊「加入術語表」 + Then 彈出輸入框讓使用者確認原文和目標翻譯,確認後儲存到 glossary.json + +- Given 使用者打開術語表管理面板 + When 新增/編輯/刪除術語 + Then 變更即時生效,並持久化到 glossary.json + +- Given 術語表有 500 筆條目 + When 使用者嘗試新增第 501 筆 + Then 系統拒絕並提示「術語表已達上限」 + +### 視覺描述 + +- Given 影片音訊 90% 以上為靜音 + When 系統完成靜音偵測 + Then 自動切換到視覺描述管線,擷取關鍵幀並產生描述性字幕 + +- Given 視覺模型未設定(無 VISION_MODEL 環境變數) + When 系統偵測到靜音影片 + Then 回傳明確錯誤訊息提示使用者設定視覺模型 + +- Given 視覺描述管線完成 + When 描述性字幕產生後 + Then 字幕進入翻譯和合併流程,最終輸出雙語字幕 + +## 開放問題 + +1. Vision 模型選擇 — 等使用者 TBD 確認,目前架構先用 Agno 抽象支援多 provider +2. 靜音偵測的閾值 (-40dB) 和比例 (90%) 是否需要可設定?初版先寫死觀察 +3. 關鍵幀擷取間隔(5 秒)是否適合所有影片?操作快速的影片可能需要更短間隔 +4. Glossary 是否需要 import/export 功能?初版先不做 diff --git a/frontend/package.json b/frontend/package.json index c782d2f..9524415 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -39,7 +39,7 @@ "jsdom": "^28.0.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4", + "vite": "^8.0.8", "vitest": "^4.0.18" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9471c48..e3d9b5a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -9,7 +9,7 @@ importers: dependencies: '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -60,8 +60,8 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.13) '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.1.3(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2)) + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1)) eslint: specifier: ^9.39.1 version: 9.39.2(jiti@2.6.1) @@ -84,11 +84,11 @@ importers: specifier: ^8.46.4 version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) vite: - specifier: ^7.2.4 - version: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2) + specifier: ^8.0.8 + version: 8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2) + version: 4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.32.0) packages: '@acemir/cssom@0.9.31': @@ -179,13 +179,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.28.6': - resolution: - { - integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==, - } - engines: { node: '>=6.9.0' } - '@babel/helper-string-parser@7.27.1': resolution: { @@ -222,24 +215,6 @@ packages: engines: { node: '>=6.0.0' } hasBin: true - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: - { - integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==, - } - engines: { node: '>=6.9.0' } - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: - { - integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==, - } - engines: { node: '>=6.9.0' } - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.6': resolution: { @@ -317,6 +292,24 @@ packages: } engines: { node: '>=20.19.0' } + '@emnapi/core@1.9.2': + resolution: + { + integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==, + } + + '@emnapi/runtime@1.9.2': + resolution: + { + integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==, + } + + '@emnapi/wasi-threads@1.2.1': + resolution: + { + integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==, + } + '@esbuild/aix-ppc64@0.27.3': resolution: { @@ -687,10 +680,165 @@ packages: integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, } - '@rolldown/pluginutils@1.0.0-rc.2': + '@napi-rs/wasm-runtime@1.1.4': + resolution: + { + integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==, + } + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.126.0': + resolution: + { + integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==, + } + + '@rolldown/binding-android-arm64@1.0.0-rc.16': + resolution: + { + integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.16': + resolution: + { + integrity: sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.16': + resolution: + { + integrity: sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.16': + resolution: + { + integrity: sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': + resolution: + { + integrity: sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': + resolution: + { + integrity: sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': resolution: { - integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==, + integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': + resolution: + { + integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': + resolution: + { + integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': + resolution: + { + integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': + resolution: + { + integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': + resolution: + { + integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': + resolution: + { + integrity: sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': + resolution: + { + integrity: sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': + resolution: + { + integrity: sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.16': + resolution: + { + integrity: sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==, + } + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: + { + integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==, } '@rollup/rollup-android-arm-eabi@4.57.1': @@ -1075,34 +1223,16 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@types/aria-query@5.0.4': - resolution: - { - integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==, - } - - '@types/babel__core@7.20.5': + '@tybys/wasm-util@0.10.1': resolution: { - integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, + integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==, } - '@types/babel__generator@7.27.0': - resolution: - { - integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==, - } - - '@types/babel__template@7.4.4': - resolution: - { - integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==, - } - - '@types/babel__traverse@7.28.0': + '@types/aria-query@5.0.4': resolution: { - integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, + integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==, } '@types/chai@5.2.3': @@ -1238,14 +1368,21 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - '@vitejs/plugin-react@5.1.3': + '@vitejs/plugin-react@6.0.1': resolution: { - integrity: sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==, + integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==, } engines: { node: ^20.19.0 || >=22.12.0 } peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true '@vitest/expect@4.0.18': resolution: @@ -2019,6 +2156,15 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.32.0: + resolution: + { + integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.30.2: resolution: { @@ -2028,6 +2174,15 @@ packages: cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.32.0: + resolution: + { + integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.30.2: resolution: { @@ -2037,6 +2192,15 @@ packages: cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: + { + integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.30.2: resolution: { @@ -2046,6 +2210,15 @@ packages: cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.32.0: + resolution: + { + integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.30.2: resolution: { @@ -2055,6 +2228,15 @@ packages: cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: + { + integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.30.2: resolution: { @@ -2064,6 +2246,15 @@ packages: cpu: [arm64] os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: + { + integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-musl@1.30.2: resolution: { @@ -2073,6 +2264,15 @@ packages: cpu: [arm64] os: [linux] + lightningcss-linux-arm64-musl@1.32.0: + resolution: + { + integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + lightningcss-linux-x64-gnu@1.30.2: resolution: { @@ -2082,6 +2282,15 @@ packages: cpu: [x64] os: [linux] + lightningcss-linux-x64-gnu@1.32.0: + resolution: + { + integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + lightningcss-linux-x64-musl@1.30.2: resolution: { @@ -2091,6 +2300,15 @@ packages: cpu: [x64] os: [linux] + lightningcss-linux-x64-musl@1.32.0: + resolution: + { + integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + lightningcss-win32-arm64-msvc@1.30.2: resolution: { @@ -2100,6 +2318,15 @@ packages: cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: + { + integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.30.2: resolution: { @@ -2109,6 +2336,15 @@ packages: cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: + { + integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [win32] + lightningcss@1.30.2: resolution: { @@ -2116,6 +2352,13 @@ packages: } engines: { node: '>= 12.0.0' } + lightningcss@1.32.0: + resolution: + { + integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==, + } + engines: { node: '>= 12.0.0' } + locate-path@6.0.0: resolution: { @@ -2288,6 +2531,20 @@ packages: } engines: { node: '>=12' } + picomatch@4.0.4: + resolution: + { + integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==, + } + engines: { node: '>=12' } + + postcss@8.5.10: + resolution: + { + integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==, + } + engines: { node: ^10 || ^12 || >=14 } + postcss@8.5.6: resolution: { @@ -2349,13 +2606,6 @@ packages: integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==, } - react-refresh@0.18.0: - resolution: - { - integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==, - } - engines: { node: '>=0.10.0' } - react@19.2.4: resolution: { @@ -2384,6 +2634,14 @@ packages: } engines: { node: '>=4' } + rolldown@1.0.0-rc.16: + resolution: + { + integrity: sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + rollup@4.57.1: resolution: { @@ -2525,6 +2783,13 @@ packages: } engines: { node: '>=12.0.0' } + tinyglobby@0.2.16: + resolution: + { + integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==, + } + engines: { node: '>=12.0.0' } + tinyrainbow@3.0.3: resolution: { @@ -2568,6 +2833,12 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + type-check@0.4.0: resolution: { @@ -2672,6 +2943,52 @@ packages: yaml: optional: true + vite@8.0.9: + resolution: + { + integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@4.0.18: resolution: { @@ -2893,8 +3210,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -2910,16 +3225,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/runtime@7.28.6': {} '@babel/template@7.28.6': @@ -2967,6 +3272,22 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -3123,7 +3444,67 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@rolldown/pluginutils@1.0.0-rc.2': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.126.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.16': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -3263,12 +3644,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1) '@testing-library/dom@10.4.1': dependencies: @@ -3304,28 +3685,12 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@types/aria-query@5.0.4': {} - - '@types/babel__core@7.20.5': + '@tybys/wasm-util@0.10.1': dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + tslib: 2.8.1 + optional: true - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': dependencies: @@ -3441,17 +3806,10 @@ snapshots: '@typescript-eslint/types': 8.54.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react@5.1.3(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@6.0.1(vite@8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.2 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2) - transitivePeerDependencies: - - supports-color + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1) '@vitest/expect@4.0.18': dependencies: @@ -3462,13 +3820,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.32.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -3765,6 +4123,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3913,36 +4275,69 @@ snapshots: lightningcss-android-arm64@1.30.2: optional: true + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.30.2: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-x64@1.30.2: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.30.2: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.30.2: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.30.2: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.30.2: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.30.2: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.30.2: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.30.2: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.30.2: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss@1.30.2: dependencies: detect-libc: 2.1.2 @@ -3959,6 +4354,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4038,6 +4449,14 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -4072,8 +4491,6 @@ snapshots: react-is@17.0.2: {} - react-refresh@0.18.0: {} - react@19.2.4: {} redent@3.0.0: @@ -4085,6 +4502,27 @@ snapshots: resolve-from@4.0.0: {} + rolldown@1.0.0-rc.16: + dependencies: + '@oxc-project/types': 0.126.0 + '@rolldown/pluginutils': 1.0.0-rc.16 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.16 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.16 + '@rolldown/binding-darwin-x64': 1.0.0-rc.16 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.16 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.16 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.16 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.16 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.16 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.16 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.16 + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -4167,6 +4605,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinyrainbow@3.0.3: {} tldts-core@7.0.23: {} @@ -4187,6 +4630,9 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.8.1: + optional: true + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4222,7 +4668,7 @@ snapshots: dependencies: react: 19.2.4 - vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -4234,12 +4680,25 @@ snapshots: '@types/node': 24.10.11 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.32.0 + + vite@8.0.9(@types/node@24.10.11)(esbuild@0.27.3)(jiti@2.6.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.16 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.10.11 + esbuild: 0.27.3 + fsevents: 2.3.3 + jiti: 2.6.1 - vitest@4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2): + vitest@4.0.18(@types/node@24.10.11)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.32.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.32.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -4256,7 +4715,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@24.10.11)(jiti@2.6.1)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.11 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfc0780..dbdc37d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,6 +64,7 @@ function App() { status={state.status} progress={state.progress} currentStep={state.currentStep} + subtitleSource={state.subtitleSource ?? undefined} /> )} @@ -156,6 +157,7 @@ function App() { status={state.status} progress={state.progress} currentStep={state.currentStep} + subtitleSource={state.subtitleSource ?? undefined} steps={SUBTITLE_STEPS} /> @@ -225,6 +227,7 @@ function App() { status={state.status} progress={state.progress} currentStep={state.currentStep} + subtitleSource={state.subtitleSource ?? undefined} /> )} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f3687ce..1fac7d1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,7 @@ import { FileType } from '../constants'; import type { + GlossaryEntry, + GlossaryListResponse, JobCreateRequest, JobCreateResponse, JobStatusResponse, @@ -144,6 +146,48 @@ class ApiClient { getDownloadUrl(jobId: string, fileType: FileType): string { return `${this.baseUrl}/api/jobs/${jobId}/download/${fileType}`; } + + async getGlossary(): Promise { + const response = await fetch(`${this.baseUrl}/api/glossary`); + if (!response.ok) { + throw await ApiError.fromResponse(response); + } + const data: GlossaryListResponse = await response.json(); + return data.entries; + } + + async addGlossaryEntry(source: string, target: string): Promise { + const response = await fetch(`${this.baseUrl}/api/glossary`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source, target }), + }); + if (!response.ok) { + throw await ApiError.fromResponse(response); + } + return response.json(); + } + + async updateGlossaryEntry(source: string, target: string): Promise { + const response = await fetch(`${this.baseUrl}/api/glossary/${encodeURIComponent(source)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source, target }), + }); + if (!response.ok) { + throw await ApiError.fromResponse(response); + } + return response.json(); + } + + async deleteGlossaryEntry(source: string): Promise { + const response = await fetch(`${this.baseUrl}/api/glossary/${encodeURIComponent(source)}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw await ApiError.fromResponse(response); + } + } } export const apiClient = new ApiClient(); diff --git a/frontend/src/components/GlossaryPanel.tsx b/frontend/src/components/GlossaryPanel.tsx new file mode 100644 index 0000000..bb5e1ea --- /dev/null +++ b/frontend/src/components/GlossaryPanel.tsx @@ -0,0 +1,245 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, Pencil, Trash2, Check, X } from 'lucide-react'; +import type { GlossaryEntry } from '@/types'; +import { apiClient } from '@/api/client'; + +export function GlossaryPanel() { + const { t } = useTranslation(); + const [entries, setEntries] = useState([]); + const [isAdding, setIsAdding] = useState(false); + const [editingSource, setEditingSource] = useState(null); + const [newSource, setNewSource] = useState(''); + const [newTarget, setNewTarget] = useState(''); + const [editTarget, setEditTarget] = useState(''); + const [error, setError] = useState(null); + + const loadEntries = useCallback(async () => { + try { + const data = await apiClient.getGlossary(); + setEntries(data); + } catch { + setError('Failed to load glossary'); + } + }, []); + + useEffect(() => { + loadEntries(); + }, [loadEntries]); + + const handleAdd = async () => { + if (!newSource.trim()) return; + try { + setError(null); + const newEntry = await apiClient.addGlossaryEntry( + newSource.trim(), + newTarget.trim() || newSource.trim() + ); + setEntries(prev => + [...prev, newEntry].sort((a, b) => + a.source.toLowerCase().localeCompare(b.source.toLowerCase()) + ) + ); + setNewSource(''); + setNewTarget(''); + setIsAdding(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add term'); + } + }; + + const handleStartEdit = (entry: GlossaryEntry) => { + setEditingSource(entry.source); + setEditTarget(entry.target); + setIsAdding(false); + }; + + const handleUpdate = async (source: string) => { + try { + setError(null); + await apiClient.updateGlossaryEntry(source, editTarget.trim()); + setEntries(prev => + prev.map(e => (e.source === source ? { ...e, target: editTarget.trim() } : e)) + ); + setEditingSource(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update term'); + } + }; + + const handleCancelEdit = () => { + setEditingSource(null); + setEditTarget(''); + }; + + const handleDelete = async (source: string) => { + try { + setError(null); + await apiClient.deleteGlossaryEntry(source); + setEntries(prev => prev.filter(e => e.source !== source)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete term'); + } + }; + + const handleCancelAdd = () => { + setIsAdding(false); + setNewSource(''); + setNewTarget(''); + setError(null); + }; + + return ( +
+ {/* Panel header */} +
+

+ {t('glossary.title')} +

+ +
+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Add new entry form */} + {isAdding && ( +
+
+ setNewSource(e.target.value)} + placeholder={t('glossary.sourcePlaceholder')} + className="flex-1 text-sm border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-black" + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') handleAdd(); + if (e.key === 'Escape') handleCancelAdd(); + }} + /> + + setNewTarget(e.target.value)} + placeholder={t('glossary.targetPlaceholder')} + className="flex-1 text-sm border border-gray-300 rounded px-3 py-2 focus:outline-none focus:border-black" + onKeyDown={e => { + if (e.key === 'Enter') handleAdd(); + if (e.key === 'Escape') handleCancelAdd(); + }} + /> +
+ + +
+
+
+ )} + + {/* Entries list */} +
+ {entries.length === 0 && !isAdding ? ( +

{t('glossary.empty')}

+ ) : ( + entries.map(entry => ( +
+ {editingSource === entry.source ? ( + /* Inline edit row */ + <> + {entry.source} + + setEditTarget(e.target.value)} + className="flex-1 text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:border-black" + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') handleUpdate(entry.source); + if (e.key === 'Escape') handleCancelEdit(); + }} + /> +
+ + +
+ + ) : ( + /* Display row */ + <> + {entry.source} + + {entry.target} +
+ + +
+ + )} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/ProgressTracker.tsx b/frontend/src/components/ProgressTracker.tsx index 17aeff3..4a41fbe 100644 --- a/frontend/src/components/ProgressTracker.tsx +++ b/frontend/src/components/ProgressTracker.tsx @@ -1,16 +1,18 @@ import { useTranslation } from 'react-i18next'; -import { PIPELINE_STEPS, type JobStatus } from '../constants'; +import { PIPELINE_STEPS, SubtitleSource, type JobStatus } from '../constants'; interface ProgressTrackerProps { status: JobStatus | null; progress: number; currentStep: string | null; + subtitleSource?: string; steps?: readonly JobStatus[]; } export function ProgressTracker({ status, progress, + subtitleSource, steps = PIPELINE_STEPS, }: ProgressTrackerProps) { const { t } = useTranslation(); @@ -49,6 +51,17 @@ export function ProgressTracker({ style={{ width: `${progress}%` }} /> + + {/* Subtitle source badge */} + {subtitleSource && ( +

+ {t('progress.subtitleSource')} + {': '} + {subtitleSource === SubtitleSource.YOUTUBE_MANUAL + ? t('progress.subtitleSourceYoutube') + : t('progress.subtitleSourceWhisper')} +

+ )} ); } diff --git a/frontend/src/components/SubtitleEditor.tsx b/frontend/src/components/SubtitleEditor.tsx index 69d1d91..5a6dd37 100644 --- a/frontend/src/components/SubtitleEditor.tsx +++ b/frontend/src/components/SubtitleEditor.tsx @@ -1,10 +1,12 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { BookOpen } from 'lucide-react'; import { apiClient } from '@/api/client'; import { FileType } from '@/constants'; import { parseSrt, serializeSrt, srtTimeToSeconds, isValidSrtTime } from '@/utils/srt'; import { triggerDownload } from '@/utils/download'; import type { SrtEntry } from '@/types'; +import { GlossaryPanel } from './GlossaryPanel'; interface SubtitleEditorProps { jobId: string; @@ -35,6 +37,7 @@ export function SubtitleEditor({ jobId, onBurn, isBurning }: SubtitleEditorProps const [retranslateChoices, setRetranslateChoices] = useState>( {} ); + const [isGlossaryOpen, setIsGlossaryOpen] = useState(false); const videoRef = useRef(null); const trackRef = useRef(null); const [currentTime, setCurrentTime] = useState(0); @@ -350,6 +353,17 @@ export function SubtitleEditor({ jobId, onBurn, isBurning }: SubtitleEditorProps

{t('editor.title')}

+
+ {isGlossaryOpen && } +

{t('editor.retranslateHint', { count: selectedCount })} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index d7c24a4..f90ddfc 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -42,3 +42,9 @@ export const PIPELINE_STEPS = [ JobStatus.TRANSLATING, JobStatus.MERGING, ] as const; + +export const SubtitleSource = { + WHISPER: 'whisper', + YOUTUBE_MANUAL: 'youtube_manual', +} as const; +export type SubtitleSource = (typeof SubtitleSource)[keyof typeof SubtitleSource]; diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index 77214c4..5dc0ce8 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -19,6 +19,7 @@ interface JobState { status: JobStatus | null; progress: number; currentStep: string | null; + subtitleSource: string | null; error: { code: string; message: string; detail?: string } | null; } @@ -44,6 +45,7 @@ const initialState: JobState = { status: null, progress: 0, currentStep: null, + subtitleSource: null, error: null, }; @@ -59,6 +61,7 @@ function jobReducer(state: JobState, action: JobAction): JobState { status: action.data.status, progress: action.data.progress, currentStep: action.data.current_step, + subtitleSource: action.data.subtitle_source ?? state.subtitleSource, }; case 'DOWNLOAD_COMPLETE': return { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index b882099..cf00c79 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -51,7 +51,10 @@ "merging": "Merging subtitles...", "burning": "Burning subtitles...", "completed": "Completed!", - "failed": "Processing failed" + "failed": "Processing failed", + "subtitleSource": "Source", + "subtitleSourceYoutube": "YouTube (manual)", + "subtitleSourceWhisper": "Whisper" }, "download": { "title": "Download Results", @@ -107,6 +110,22 @@ "burning": "Burning...", "deleteEntry": "Delete entry" }, + "glossary": { + "title": "Glossary", + "source": "Source", + "target": "Target", + "add": "Add Term", + "edit": "Edit", + "delete": "Delete", + "save": "Save", + "cancel": "Cancel", + "empty": "No terms yet. Click the button above to add one.", + "addToGlossary": "Add to Glossary", + "sourcePlaceholder": "Source term", + "targetPlaceholder": "Keep original or custom translation", + "confirmDelete": "Delete this term?", + "full": "Glossary is full" + }, "disclaimer": { "title": "Please Read Before Downloading", "tool_desc": "BilingualSub is an open-source bilingual subtitle generator designed to assist with language learning and cross-language content comprehension.", diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json index e3a1123..c31281f 100644 --- a/frontend/src/i18n/zh-TW.json +++ b/frontend/src/i18n/zh-TW.json @@ -51,7 +51,10 @@ "merging": "合併字幕中...", "burning": "燒錄字幕中...", "completed": "完成!", - "failed": "處理失敗" + "failed": "處理失敗", + "subtitleSource": "字幕來源", + "subtitleSourceYoutube": "YouTube(手動上傳)", + "subtitleSourceWhisper": "Whisper 語音辨識" }, "download": { "title": "下載結果", @@ -107,6 +110,22 @@ "burning": "燒錄中...", "deleteEntry": "刪除字幕" }, + "glossary": { + "title": "術語表", + "source": "原文", + "target": "譯文", + "add": "新增術語", + "edit": "編輯", + "delete": "刪除", + "save": "儲存", + "cancel": "取消", + "empty": "尚無術語,點擊上方按鈕新增", + "addToGlossary": "加入術語表", + "sourcePlaceholder": "原文詞彙", + "targetPlaceholder": "保留原文或自訂翻譯", + "confirmDelete": "確定刪除此術語?", + "full": "術語表已達上限" + }, "disclaimer": { "title": "下載前請閱讀", "tool_desc": "BilingualSub 是一款開源的雙語字幕生成工具,旨在協助語言學習與跨語言內容理解。", diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c8f1c1b..698c53b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -32,6 +32,7 @@ export interface SSEProgressData { progress: number; current_step: string | null; message: string | null; + subtitle_source?: string; } export interface SSEHandlers { @@ -77,3 +78,12 @@ export interface SrtEntry { translated: string; // first line: editable original: string; // second line: read-only } + +export interface GlossaryEntry { + source: string; + target: string; +} + +export interface GlossaryListResponse { + entries: GlossaryEntry[]; +} diff --git a/src/bilingualsub/api/app.py b/src/bilingualsub/api/app.py index b402fac..61fb8c4 100644 --- a/src/bilingualsub/api/app.py +++ b/src/bilingualsub/api/app.py @@ -19,6 +19,8 @@ from bilingualsub.api.jobs import JobManager from bilingualsub.api.logging import setup_logging from bilingualsub.api.routes import router +from bilingualsub.core.glossary import GlossaryManager +from bilingualsub.utils.config import get_settings if TYPE_CHECKING: from collections.abc import AsyncGenerator @@ -30,6 +32,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: setup_logging() manager = JobManager() app.state.job_manager = manager + settings = get_settings() + app.state.glossary_manager = GlossaryManager(Path(settings.glossary_path)) await manager.start_cleanup_loop() yield await manager.stop_cleanup_loop() diff --git a/src/bilingualsub/api/constants.py b/src/bilingualsub/api/constants.py index f15bc53..a349f6e 100644 --- a/src/bilingualsub/api/constants.py +++ b/src/bilingualsub/api/constants.py @@ -37,6 +37,13 @@ class SSEEvent(StrEnum): PING = "ping" +class SubtitleSource(StrEnum): + """Source of the original subtitle track.""" + + WHISPER = "whisper" + YOUTUBE_MANUAL = "youtube_manual" + + JOB_TTL_SECONDS = 1800 CLEANUP_INTERVAL_SECONDS = 300 SSE_KEEPALIVE_SECONDS = 30 diff --git a/src/bilingualsub/api/jobs.py b/src/bilingualsub/api/jobs.py index 041e290..4e64b85 100644 --- a/src/bilingualsub/api/jobs.py +++ b/src/bilingualsub/api/jobs.py @@ -45,6 +45,8 @@ class Job: video_height: int = 0 video_title: str = "" video_description: str = "" + glossary_text: str = "" + subtitle_source: str = "" output_files: dict[FileType, Path] = field(default_factory=dict) event_queue: asyncio.Queue[dict[str, object]] = field(default_factory=asyncio.Queue) created_at: float = field(default_factory=time.monotonic) diff --git a/src/bilingualsub/api/pipeline.py b/src/bilingualsub/api/pipeline.py index 138afea..8913692 100644 --- a/src/bilingualsub/api/pipeline.py +++ b/src/bilingualsub/api/pipeline.py @@ -10,7 +10,7 @@ import structlog -from bilingualsub.api.constants import FileType, JobStatus, SSEEvent +from bilingualsub.api.constants import FileType, JobStatus, SSEEvent, SubtitleSource from bilingualsub.api.errors import PipelineError if TYPE_CHECKING: @@ -28,6 +28,7 @@ transcribe_audio, translate_subtitle, ) +from bilingualsub.core.subtitle_fetcher import fetch_manual_subtitle from bilingualsub.formats import serialize_bilingual_ass, serialize_srt from bilingualsub.utils import ( FFmpegError, @@ -55,20 +56,24 @@ def _send_progress( progress: float, current_step: str, message: str, + extra: dict[str, object] | None = None, ) -> None: """Update job state and enqueue an SSE progress event.""" job.status = status job.progress = progress job.current_step = current_step + data: dict[str, object] = { + "status": str(status), + "progress": progress, + "current_step": current_step, + "message": message, + } + if extra: + data.update(extra) job.event_queue.put_nowait( { "event": SSEEvent.PROGRESS, - "data": { - "status": str(status), - "progress": progress, - "current_step": current_step, - "message": message, - }, + "data": data, } ) @@ -270,7 +275,6 @@ async def run_download(job: Job) -> None: try: video_path, metadata = await _acquire_video(job, work_dir, log) - # Trimming is now handled during download via start_time/end_time await _extract_audio_step(job, video_path, work_dir, log) # Save metadata for subtitle phase @@ -294,6 +298,40 @@ async def run_download(job: Job) -> None: ) +async def _merge_and_serialize( + job: Job, + original_sub: Subtitle, + translated_sub: Subtitle, + work_dir: Path, + log: structlog.stdlib.BoundLogger, +) -> None: + """Merge original + translated subtitles and serialize to SRT/ASS.""" + _send_progress(job, JobStatus.MERGING, 70.0, "merge", "Merging bilingual subtitles") + t0 = time.monotonic() + + merged_entries = await asyncio.to_thread( + merge_subtitles, original_sub.entries, translated_sub.entries + ) + merged_sub = Subtitle(entries=merged_entries) + + srt_content = serialize_srt(merged_sub) + srt_path = work_dir / "subtitle.srt" + srt_path.write_text(srt_content, encoding="utf-8") + job.output_files[FileType.SRT] = srt_path + + ass_content = serialize_bilingual_ass( + original_sub, + translated_sub, + video_width=job.video_width, + video_height=job.video_height, + ) + ass_path = work_dir / "subtitle.ass" + ass_path.write_text(ass_content, encoding="utf-8") + job.output_files[FileType.ASS] = ass_path + + log.info("step_done", step="merge", duration_ms=int((time.monotonic() - t0) * 1000)) + + async def run_subtitle(job: Job) -> None: """Phase 2: Transcribe -> Translate -> Merge -> Serialize.""" log = logger.bind(job_id=job.id) @@ -302,23 +340,56 @@ async def run_subtitle(job: Job) -> None: audio_path = job.output_files[FileType.AUDIO] work_dir = audio_path.parent - # --- Step: Transcribe --- - _send_progress( - job, JobStatus.TRANSCRIBING, 20.0, "transcribe", "Transcribing audio" - ) - t0 = time.monotonic() - original_sub = await asyncio.to_thread( - transcribe_audio, audio_path, language=job.source_lang - ) - log.info( - "step_done", - step="transcribe", - duration_ms=int((time.monotonic() - t0) * 1000), - ) + original_sub = None + subtitle_source = SubtitleSource.WHISPER + + if job.source_url: + _send_progress( + job, + JobStatus.TRANSCRIBING, + 20.0, + "transcribe", + "Checking for manual subtitles", + ) + t0 = time.monotonic() + original_sub = await asyncio.to_thread( + fetch_manual_subtitle, job.source_url, job.source_lang, work_dir + ) + if original_sub is not None: + subtitle_source = SubtitleSource.YOUTUBE_MANUAL + log.info( + "step_done", + step="subtitle_fetch", + source="youtube_manual", + entries=len(original_sub.entries), + duration_ms=int((time.monotonic() - t0) * 1000), + ) + + if original_sub is None: + _send_progress( + job, JobStatus.TRANSCRIBING, 20.0, "transcribe", "Transcribing audio" + ) + t0 = time.monotonic() + original_sub = await asyncio.to_thread( + transcribe_audio, audio_path, language=job.source_lang + ) + log.info( + "step_done", + step="transcribe", + duration_ms=int((time.monotonic() - t0) * 1000), + ) + + job.subtitle_source = subtitle_source + if not isinstance(original_sub, Subtitle): + raise PipelineError("transcription_failed", "Failed to obtain subtitles") - # --- Step: Translate --- _send_progress( - job, JobStatus.TRANSLATING, 50.0, "translate", "Translating subtitles" + job, + JobStatus.TRANSLATING, + 50.0, + "translate", + "Translating subtitles", + extra={"subtitle_source": str(subtitle_source)}, ) t0 = time.monotonic() _on_translate_progress = _make_translate_progress_cb(job) @@ -330,6 +401,7 @@ async def run_subtitle(job: Job) -> None: target_lang=job.target_lang, video_title=job.video_title, video_description=job.video_description, + glossary_text=job.glossary_text, on_progress=_on_translate_progress, on_rate_limit=_on_rate_limit, ) @@ -339,40 +411,13 @@ async def run_subtitle(job: Job) -> None: duration_ms=int((time.monotonic() - t0) * 1000), ) - # --- Step: Merge & Serialize --- - _send_progress( - job, JobStatus.MERGING, 70.0, "merge", "Merging bilingual subtitles" - ) - t0 = time.monotonic() - - merged_entries = await asyncio.to_thread( - merge_subtitles, original_sub.entries, translated_sub.entries - ) - merged_sub = Subtitle(entries=merged_entries) - - srt_content = serialize_srt(merged_sub) - srt_path = work_dir / "subtitle.srt" - srt_path.write_text(srt_content, encoding="utf-8") - job.output_files[FileType.SRT] = srt_path - - ass_content = serialize_bilingual_ass( - original_sub, - translated_sub, - video_width=job.video_width, - video_height=job.video_height, - ) - ass_path = work_dir / "subtitle.ass" - ass_path.write_text(ass_content, encoding="utf-8") - job.output_files[FileType.ASS] = ass_path - - log.info( - "step_done", step="merge", duration_ms=int((time.monotonic() - t0) * 1000) - ) + await _merge_and_serialize(job, original_sub, translated_sub, work_dir, log) _send_complete(job) log.info("subtitle_complete", job_id=job.id) - except PipelineError: - raise + except PipelineError as exc: + _send_error(job, exc.code, exc.message, exc.detail or "") + log.error("subtitle_failed", error_code=exc.code, error=str(exc)) except Exception as exc: pipeline_err = _to_pipeline_error(exc) _send_error( diff --git a/src/bilingualsub/api/routes.py b/src/bilingualsub/api/routes.py index 348b5bb..97b41ba 100644 --- a/src/bilingualsub/api/routes.py +++ b/src/bilingualsub/api/routes.py @@ -20,11 +20,19 @@ JobStatus, SSEEvent, ) -from bilingualsub.api.errors import InvalidRequestError, JobNotFoundError, PipelineError +from bilingualsub.api.errors import ( + ApiError, + InvalidRequestError, + JobNotFoundError, + PipelineError, +) from bilingualsub.api.pipeline import run_burn, run_download, run_subtitle from bilingualsub.api.schemas import ( BurnRequest, ErrorDetail, + GlossaryAddRequest, + GlossaryEntrySchema, + GlossaryListResponse, JobCreateRequest, JobCreateResponse, JobStatusResponse, @@ -34,6 +42,7 @@ StartSubtitleRequest, ) from bilingualsub.core import RetranslateEntry, TranslationError, retranslate_entries +from bilingualsub.core.glossary import GlossaryError, GlossaryManager if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -115,6 +124,12 @@ def _get_job_or_404(request: Request, job_id: str) -> Job: return job +def _get_glossary_manager(request: Request) -> GlossaryManager: + """Get the GlossaryManager from app state.""" + manager: GlossaryManager = request.app.state.glossary_manager + return manager + + def _start_background_task(request: Request, coro: Any) -> None: """Start a coroutine as a background task, preventing GC.""" request.app.state._background_tasks = getattr( @@ -281,6 +296,8 @@ async def start_subtitle( job.source_lang = body.source_lang if body.target_lang: job.target_lang = body.target_lang + glossary_manager = _get_glossary_manager(request) + job.glossary_text = glossary_manager.format_for_prompt() _start_background_task(request, run_subtitle(job)) return {"status": "subtitle_started"} @@ -312,6 +329,7 @@ async def partial_retranslate( if FileType.SOURCE_VIDEO not in job.output_files: raise InvalidRequestError("Pipeline not complete") + glossary_manager = _get_glossary_manager(request) try: results = await asyncio.to_thread( retranslate_entries, @@ -328,6 +346,7 @@ async def partial_retranslate( target_lang=job.target_lang, video_title=job.video_title, video_description=job.video_description, + glossary_text=glossary_manager.format_for_prompt(), user_context=body.user_context, ) except ValueError as exc: @@ -350,6 +369,57 @@ async def partial_retranslate( ) +@router.get("/glossary", response_model=GlossaryListResponse) +async def list_glossary(request: Request) -> GlossaryListResponse: + manager = _get_glossary_manager(request) + entries = manager.get_all() + return GlossaryListResponse( + entries=[GlossaryEntrySchema(source=e.source, target=e.target) for e in entries] + ) + + +@router.post("/glossary", response_model=GlossaryEntrySchema, status_code=201) +async def add_glossary_entry( + body: GlossaryAddRequest, request: Request +) -> GlossaryEntrySchema: + manager = _get_glossary_manager(request) + try: + entry = manager.add(body.source, body.target) + except GlossaryError as exc: + raise ApiError( + status_code=400, code="GLOSSARY_ERROR", message=str(exc) + ) from exc + return GlossaryEntrySchema(source=entry.source, target=entry.target) + + +@router.put("/glossary/{source}", response_model=GlossaryEntrySchema) +async def update_glossary_entry( + source: str, body: GlossaryAddRequest, request: Request +) -> GlossaryEntrySchema: + manager = _get_glossary_manager(request) + try: + entry = manager.update(source, body.target) + except GlossaryError as exc: + is_not_found = "not found" in str(exc).lower() + raise ApiError( + status_code=404 if is_not_found else 400, + code="GLOSSARY_NOT_FOUND" if is_not_found else "GLOSSARY_ERROR", + message=str(exc), + ) from exc + return GlossaryEntrySchema(source=entry.source, target=entry.target) + + +@router.delete("/glossary/{source}", status_code=204) +async def delete_glossary_entry(source: str, request: Request) -> None: + manager = _get_glossary_manager(request) + try: + manager.delete(source) + except GlossaryError as exc: + raise ApiError( + status_code=404, code="GLOSSARY_NOT_FOUND", message=str(exc) + ) from exc + + @router.get("/health") async def health_check() -> dict[str, str]: """Health check endpoint.""" diff --git a/src/bilingualsub/api/schemas.py b/src/bilingualsub/api/schemas.py index 40d7059..0ed3f0f 100644 --- a/src/bilingualsub/api/schemas.py +++ b/src/bilingualsub/api/schemas.py @@ -122,3 +122,18 @@ class SSEProgressData(BaseModel): progress: float current_step: str | None = None message: str | None = None + subtitle_source: str | None = None + + +class GlossaryEntrySchema(BaseModel): + source: str + target: str + + +class GlossaryAddRequest(BaseModel): + source: str + target: str + + +class GlossaryListResponse(BaseModel): + entries: list[GlossaryEntrySchema] diff --git a/src/bilingualsub/core/__init__.py b/src/bilingualsub/core/__init__.py index 8b1843f..dfea0c4 100644 --- a/src/bilingualsub/core/__init__.py +++ b/src/bilingualsub/core/__init__.py @@ -5,8 +5,10 @@ VideoMetadata, download_video, ) +from bilingualsub.core.glossary import GlossaryEntry, GlossaryError, GlossaryManager from bilingualsub.core.merger import merge_subtitles from bilingualsub.core.subtitle import Subtitle, SubtitleEntry +from bilingualsub.core.subtitle_fetcher import SubtitleFetchError, fetch_manual_subtitle from bilingualsub.core.transcriber import TranscriptionError, transcribe_audio from bilingualsub.core.translator import ( RetranslateEntry, @@ -17,13 +19,18 @@ __all__ = [ "DownloadError", + "GlossaryEntry", + "GlossaryError", + "GlossaryManager", "RetranslateEntry", "Subtitle", "SubtitleEntry", + "SubtitleFetchError", "TranscriptionError", "TranslationError", "VideoMetadata", "download_video", + "fetch_manual_subtitle", "merge_subtitles", "retranslate_entries", "transcribe_audio", diff --git a/src/bilingualsub/core/glossary.py b/src/bilingualsub/core/glossary.py new file mode 100644 index 0000000..0352e21 --- /dev/null +++ b/src/bilingualsub/core/glossary.py @@ -0,0 +1,134 @@ +"""Glossary manager for term preservation during translation.""" + +import json +import threading +from dataclasses import dataclass +from pathlib import Path + +import structlog + +logger = structlog.get_logger() + + +class GlossaryError(Exception): + """Raised when glossary operations fail.""" + + +class GlossaryNotFoundError(GlossaryError): + """Raised when a glossary term is not found.""" + + +_MAX_ENTRIES = 500 +_MAX_TERM_LENGTH = 100 + + +@dataclass +class GlossaryEntry: + source: str + target: str + + +class GlossaryManager: + """Manages glossary terms with JSON file persistence.""" + + def __init__(self, path: Path) -> None: + self._path = path + self._entries: dict[str, GlossaryEntry] = {} + self._prompt_cache: str | None = None + self._lock = threading.Lock() + self._load() + + def _load(self) -> None: + """Load glossary from JSON file. Does nothing if file does not exist.""" + if not self._path.exists(): + return + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + for e in data.get("entries", []): + try: + self._entries[e["source"]] = GlossaryEntry( + source=e["source"], target=e["target"] + ) + except (KeyError, TypeError): + logger.warning("glossary_entry_skipped", entry=e) + except json.JSONDecodeError: + logger.warning("glossary_corrupted", path=str(self._path)) + self._path.rename(self._path.with_suffix(".json.bak")) + self._entries = {} + + def _save(self) -> None: + """Persist glossary to JSON file atomically.""" + data = { + "entries": [ + {"source": e.source, "target": e.target} for e in self._sorted_entries() + ] + } + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self._path.with_suffix(".tmp") + tmp_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + tmp_path.replace(self._path) + self._prompt_cache = None + + def _sorted_entries(self) -> list[GlossaryEntry]: + return sorted(self._entries.values(), key=lambda x: x.source.lower()) + + def _validate_terms(self, source: str, target: str) -> tuple[str, str]: + source, target = source.strip(), target.strip() + if not source: + raise GlossaryError("Source term cannot be empty") + if len(source) > _MAX_TERM_LENGTH or len(target) > _MAX_TERM_LENGTH: + raise GlossaryError( + f"Term length cannot exceed {_MAX_TERM_LENGTH} characters" + ) + return source, target + + def get_all(self) -> list[GlossaryEntry]: + return self._sorted_entries() + + def add(self, source: str, target: str) -> GlossaryEntry: + source, target = self._validate_terms(source, target) + with self._lock: + if source in self._entries: + entry = GlossaryEntry(source=source, target=target) + self._entries[source] = entry + self._save() + return entry + if len(self._entries) >= _MAX_ENTRIES: + raise GlossaryError(f"Glossary is full (max {_MAX_ENTRIES} entries)") + entry = GlossaryEntry(source=source, target=target) + self._entries[source] = entry + self._save() + return entry + + def update(self, source: str, target: str) -> GlossaryEntry: + source, target = self._validate_terms(source, target) + with self._lock: + if source not in self._entries: + raise GlossaryNotFoundError(f"Term '{source}' not found") + entry = GlossaryEntry(source=source, target=target) + self._entries[source] = entry + self._save() + return entry + + def delete(self, source: str) -> None: + source = source.strip() + with self._lock: + if source not in self._entries: + raise GlossaryNotFoundError(f"Term '{source}' not found") + del self._entries[source] + self._save() + + def format_for_prompt(self) -> str: + """Format glossary as a string for injection into translation prompt.""" + if not self._entries: + return "" + if self._prompt_cache is not None: + return self._prompt_cache + lines = [f"{e.source} → {e.target}" for e in self.get_all()] + self._prompt_cache = ( + "以下是術語表,請嚴格依照此表翻譯對應的專有名詞:\n" + "\n".join(lines) # noqa: RUF001 + ) + return self._prompt_cache diff --git a/src/bilingualsub/core/subtitle_fetcher.py b/src/bilingualsub/core/subtitle_fetcher.py new file mode 100644 index 0000000..67c2f22 --- /dev/null +++ b/src/bilingualsub/core/subtitle_fetcher.py @@ -0,0 +1,142 @@ +"""Fetch manually-uploaded subtitles from video platforms via yt-dlp.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +import structlog +import yt_dlp + +from bilingualsub.formats.srt import parse_srt + +if TYPE_CHECKING: + from pathlib import Path + + from bilingualsub.core.subtitle import Subtitle + +logger = structlog.get_logger() + + +class SubtitleFetchError(Exception): + """Raised when subtitle fetching fails.""" + + +def fetch_manual_subtitle( + url: str, + lang: str, + work_dir: Path, +) -> Subtitle | None: + """Download manual subtitle for a video if available. + + Returns Subtitle object if manual subs found, None otherwise. + Never raises - logs warnings and returns None on any failure. + """ + try: + return _fetch_subtitle(url, lang, work_dir) + except Exception: + logger.warning("subtitle_fetch_failed", url=url, lang=lang, exc_info=True) + return None + + +def _fetch_subtitle(url: str, lang: str, work_dir: Path) -> Subtitle | None: + opts = { + "skip_download": True, + "writesubtitles": True, + "subtitleslangs": [lang], + "subtitlesformat": "srt", + "outtmpl": str(work_dir / "manual_sub"), + "quiet": True, + "no_warnings": True, + } + with yt_dlp.YoutubeDL(opts) as ydl: + info = ydl.extract_info(url, download=True) + + if not info: + return None + + manual_subs = info.get("subtitles", {}) + if lang not in manual_subs: + logger.info( + "no_manual_subtitle", + url=url, + lang=lang, + available=list(manual_subs.keys()), + ) + return None + + # yt-dlp may use different naming patterns, find the file + actual_path: Path | None = None + for ext in (".srt", ".vtt", ".ass"): + candidate = work_dir / f"manual_sub.{lang}{ext}" + if candidate.exists(): + actual_path = candidate + break + + if actual_path is None: + for ext in (".srt", ".vtt", ".ass"): + matches = list(work_dir.glob(f"manual_sub*.{lang}{ext}")) + if matches: + actual_path = matches[0] + break + + if actual_path is None: + logger.warning("subtitle_file_not_found", work_dir=str(work_dir)) + return None + + content = actual_path.read_text(encoding="utf-8") + + if actual_path.suffix == ".vtt": + content = vtt_to_srt(content) + + subtitle = parse_srt(content) + logger.info("manual_subtitle_fetched", lang=lang, entries=len(subtitle.entries)) + return subtitle + + +def vtt_to_srt(vtt_content: str) -> str: + """Convert WebVTT content to SRT format.""" + lines = vtt_content.strip().split("\n") + + # Skip VTT header + start_idx = 0 + for i, line in enumerate(lines): + if "-->" in line: + # Check if previous line is a cue number + start_idx = i - 1 if i > 0 and lines[i - 1].strip().isdigit() else i + break + + srt_lines = [] + # Re-number and fix timestamp format (VTT uses . for ms, SRT uses ,) + block_num = 0 + i = start_idx + while i < len(lines): + line = lines[i].strip() + if "-->" in line: + block_num += 1 + # Ensure HH:MM:SS format (VTT sometimes omits hours) + parts = line.split("-->") + fixed_parts = [] + for raw_part in parts: + stripped = raw_part.strip() + if len(stripped.split(":")) == 2: + stripped = "00:" + stripped + fixed_parts.append(stripped) + timing = " --> ".join(fixed_parts) + # Fix timestamp separators: . → , (after hour-padding so regex matches) + timing = re.sub(r"(\d{2}:\d{2}:\d{2})\.(\d{3})", r"\1,\2", timing) + + srt_lines.append(str(block_num)) + srt_lines.append(timing) + i += 1 + while i < len(lines) and lines[i].strip() and "-->" not in lines[i]: + # Strip VTT positioning tags like and alignment tags + text = re.sub(r"<[^>]+>", "", lines[i].strip()) + if text: + srt_lines.append(text) + i += 1 + srt_lines.append("") # blank line between blocks + else: + i += 1 + + return "\n".join(srt_lines) diff --git a/src/bilingualsub/core/translator.py b/src/bilingualsub/core/translator.py index 368fd0d..ab2d13d 100644 --- a/src/bilingualsub/core/translator.py +++ b/src/bilingualsub/core/translator.py @@ -93,6 +93,7 @@ def _build_translator_description( target_lang: str, video_title: str, video_description: str, + glossary_text: str = "", ) -> str: """Build agent system prompt description.""" base = ( @@ -103,13 +104,16 @@ def _build_translator_description( "字幕可能在句子中間被截斷,這是正常的,請照樣翻譯,不要提示原文不完整。" # noqa: RUF001 ) metadata_section = _build_metadata_section(video_title, video_description) - if not metadata_section: - return base - return ( - f"{base}\n\n" - "以下是影片背景資訊,請用於理解語境、專有名詞與代稱,但不要逐字照抄:" # noqa: RUF001 - f"\n{metadata_section}" - ) + result = base + if metadata_section: + result = ( + f"{base}\n\n" + "以下是影片背景資訊,請用於理解語境、專有名詞與代稱,但不要逐字照抄:" # noqa: RUF001 + f"\n{metadata_section}" + ) + if glossary_text: + result = f"{result}\n\n{glossary_text}" + return result def _strip_number_prefix(text: str) -> str: @@ -302,6 +306,7 @@ def translate_subtitle( target_lang: str = "zh-TW", video_title: str = "", video_description: str = "", + glossary_text: str = "", on_progress: Callable[[int, int], None] | None = None, on_rate_limit: Callable[[float, int, int], None] | None = None, ) -> Subtitle: @@ -337,6 +342,7 @@ def translate_subtitle( target_lang=target_lang, video_title=video_title, video_description=video_description, + glossary_text=glossary_text, ), ) @@ -451,6 +457,7 @@ def retranslate_entries( target_lang: str = "zh-TW", video_title: str = "", video_description: str = "", + glossary_text: str = "", user_context: str | None = None, ) -> dict[int, str]: """Re-translate selected subtitle entries with local context. @@ -492,6 +499,7 @@ def retranslate_entries( target_lang=target_lang, video_title=video_title, video_description=video_description, + glossary_text=glossary_text, ), ) diff --git a/src/bilingualsub/utils/config.py b/src/bilingualsub/utils/config.py index db58664..1c83ad7 100644 --- a/src/bilingualsub/utils/config.py +++ b/src/bilingualsub/utils/config.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): transcriber_model: str = "whisper-large-v3-turbo" translator_model: str = "groq:openai/gpt-oss-120b" + glossary_path: str = "glossary.json" model_config = SettingsConfigDict( env_file=".env", diff --git a/tests/unit/api/test_routes.py b/tests/unit/api/test_routes.py index d70042d..32efc77 100644 --- a/tests/unit/api/test_routes.py +++ b/tests/unit/api/test_routes.py @@ -10,13 +10,15 @@ from bilingualsub.api.constants import FileType, JobStatus from bilingualsub.api.jobs import Job, JobManager from bilingualsub.api.routes import _build_download_filename, _sanitize_filename +from bilingualsub.core.glossary import GlossaryManager @pytest.fixture -def app(): +def app(tmp_path): """Create a fresh app with manually initialised state.""" application = create_app() application.state.job_manager = JobManager() + application.state.glossary_manager = GlossaryManager(tmp_path / "glossary.json") return application @@ -80,7 +82,7 @@ async def test_get_existing_job(self, client: AsyncClient) -> None: assert response.status_code == 200 data = response.json() assert data["job_id"] == job_id - assert "status" in data + assert data["status"] == "pending" async def test_get_nonexistent_job(self, client: AsyncClient) -> None: response = await client.get("/api/jobs/nonexistent") @@ -150,6 +152,7 @@ async def test_start_subtitle_success(self, client: AsyncClient, app) -> None: response = await client.post(f"/api/jobs/{job_id}/subtitle") assert response.status_code == 200 assert response.json()["status"] == "subtitle_started" + assert job.glossary_text == "" # empty glossary yields empty string async def test_start_subtitle_with_language_override( self, client: AsyncClient, app @@ -174,6 +177,7 @@ async def test_start_subtitle_with_language_override( assert response.json()["status"] == "subtitle_started" assert job.source_lang == "ja" assert job.target_lang == "ko" + assert job.glossary_text == "" @pytest.mark.unit @@ -208,6 +212,8 @@ async def test_partial_retranslate_success(self, client: AsyncClient, app) -> No assert response.status_code == 200 data = response.json() assert data["results"] == [{"index": 2, "translated": "修正版第二句"}] + call_kwargs = mock_retranslate.call_args.kwargs + assert call_kwargs["glossary_text"] == "" # empty glossary async def test_partial_retranslate_requires_pipeline_complete( self, client: AsyncClient @@ -318,3 +324,63 @@ def test_empty_source_lang_produces_original(self) -> None: job = _make_job(title="My Video", source_lang="", target_lang="zh-TW") result = _build_download_filename(job, FileType.SRT) assert result == "My Video (original).srt" + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestGlossaryRoutes: + async def test_list_empty_glossary(self, client: AsyncClient) -> None: + response = await client.get("/api/glossary") + assert response.status_code == 200 + assert response.json() == {"entries": []} + + async def test_add_glossary_entry(self, client: AsyncClient) -> None: + response = await client.post( + "/api/glossary", + json={"source": "Agent", "target": "Agent"}, + ) + assert response.status_code == 201 + assert response.json() == {"source": "Agent", "target": "Agent"} + + async def test_add_then_list(self, client: AsyncClient) -> None: + await client.post("/api/glossary", json={"source": "Agent", "target": "Agent"}) + response = await client.get("/api/glossary") + entries = response.json()["entries"] + assert len(entries) == 1 + assert entries[0]["source"] == "Agent" + assert entries[0]["target"] == "Agent" + + async def test_update_glossary_entry(self, client: AsyncClient) -> None: + await client.post("/api/glossary", json={"source": "Agent", "target": "Agent"}) + response = await client.put( + "/api/glossary/Agent", + json={"source": "Agent", "target": "代理"}, + ) + assert response.status_code == 200 + assert response.json()["target"] == "代理" + + async def test_update_nonexistent_returns_404(self, client: AsyncClient) -> None: + response = await client.put( + "/api/glossary/nope", + json={"source": "nope", "target": "value"}, + ) + assert response.status_code == 404 + + async def test_delete_glossary_entry(self, client: AsyncClient) -> None: + await client.post("/api/glossary", json={"source": "Agent", "target": "Agent"}) + response = await client.delete("/api/glossary/Agent") + assert response.status_code == 204 + list_response = await client.get("/api/glossary") + assert list_response.json()["entries"] == [] + + async def test_delete_nonexistent_returns_404(self, client: AsyncClient) -> None: + response = await client.delete("/api/glossary/nope") + assert response.status_code == 404 + + async def test_add_empty_source_returns_400(self, client: AsyncClient) -> None: + response = await client.post( + "/api/glossary", + json={"source": "", "target": "value"}, + ) + assert response.status_code == 400 + assert response.json()["code"] == "GLOSSARY_ERROR" diff --git a/tests/unit/core/test_glossary.py b/tests/unit/core/test_glossary.py new file mode 100644 index 0000000..4598683 --- /dev/null +++ b/tests/unit/core/test_glossary.py @@ -0,0 +1,121 @@ +"""Tests for GlossaryManager.""" + +import pytest + +from bilingualsub.core.glossary import GlossaryEntry, GlossaryError, GlossaryManager + + +@pytest.mark.unit +class TestGlossaryManager: + def test_add_and_get_all(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + entry = manager.add("Agent", "Agent") + assert entry == GlossaryEntry(source="Agent", target="Agent") + assert manager.get_all() == [entry] + + def test_add_strips_whitespace(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + entry = manager.add(" Agent ", " Agent ") + assert entry.source == "Agent" + assert entry.target == "Agent" + + def test_add_empty_source_raises(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + with pytest.raises(GlossaryError, match="cannot be empty"): + manager.add("", "target") + + def test_add_duplicate_upserts(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + manager.add("Agent", "Agent") + updated = manager.add("Agent", "代理") + assert updated.target == "代理" + assert len(manager.get_all()) == 1 + + def test_add_over_max_length_raises(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + with pytest.raises(GlossaryError, match="cannot exceed"): + manager.add("x" * 101, "target") + + def test_add_over_max_entries_raises(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + for i in range(500): + manager.add(f"term{i:04d}", f"value{i}") + with pytest.raises(GlossaryError, match="full"): + manager.add("one_too_many", "value") + + def test_update_existing(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + manager.add("Agent", "Agent") + updated = manager.update("Agent", "代理") + assert updated.target == "代理" + + def test_update_nonexistent_raises(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + with pytest.raises(GlossaryError, match="not found"): + manager.update("nope", "value") + + def test_delete_existing(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + manager.add("Agent", "Agent") + manager.delete("Agent") + assert manager.get_all() == [] + + def test_delete_nonexistent_raises(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + with pytest.raises(GlossaryError, match="not found"): + manager.delete("nope") + + def test_persistence_round_trip(self, tmp_path): + path = tmp_path / "glossary.json" + manager1 = GlossaryManager(path) + manager1.add("Agent", "Agent") + manager1.add("Skills", "Skills") + + manager2 = GlossaryManager(path) + entries = manager2.get_all() + assert len(entries) == 2 + sources = [e.source for e in entries] + assert "Agent" in sources + assert "Skills" in sources + + def test_corrupted_json_creates_backup(self, tmp_path): + path = tmp_path / "glossary.json" + path.write_text("{invalid json", encoding="utf-8") + manager = GlossaryManager(path) + assert manager.get_all() == [] + assert path.with_suffix(".json.bak").exists() + + def test_format_for_prompt_empty(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + assert manager.format_for_prompt() == "" + + def test_format_for_prompt_with_entries(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + manager.add("Agent", "Agent") + manager.add("Skills", "Skills") + prompt = manager.format_for_prompt() + assert "Agent → Agent" in prompt + assert "Skills → Skills" in prompt + assert "術語表" in prompt + + def test_format_for_prompt_reflects_mutations(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + manager.add("Agent", "Agent") + prompt_before = manager.format_for_prompt() + assert "Agent" in prompt_before + assert "Skills" not in prompt_before + manager.add("Skills", "Skills") + prompt_after = manager.format_for_prompt() + assert "Skills" in prompt_after + + def test_nonexistent_file_starts_empty(self, tmp_path): + manager = GlossaryManager(tmp_path / "nonexistent" / "glossary.json") + assert manager.get_all() == [] + + def test_get_all_sorted_case_insensitive(self, tmp_path): + manager = GlossaryManager(tmp_path / "glossary.json") + manager.add("Zebra", "Zebra") + manager.add("agent", "agent") + manager.add("Alpha", "Alpha") + entries = manager.get_all() + assert [e.source for e in entries] == ["agent", "Alpha", "Zebra"] diff --git a/tests/unit/core/test_subtitle_fetcher.py b/tests/unit/core/test_subtitle_fetcher.py new file mode 100644 index 0000000..3fa46c3 --- /dev/null +++ b/tests/unit/core/test_subtitle_fetcher.py @@ -0,0 +1,57 @@ +"""Tests for subtitle_fetcher VTT-to-SRT conversion.""" + +import pytest + +from bilingualsub.core.subtitle_fetcher import vtt_to_srt + + +@pytest.mark.unit +class TestVttToSrt: + def test_basic_vtt_conversion(self): + vtt = """WEBVTT + +1 +00:00:01.000 --> 00:00:04.000 +Hello world + +2 +00:00:05.000 --> 00:00:08.000 +Second line""" + srt = vtt_to_srt(vtt) + assert "00:00:01,000 --> 00:00:04,000" in srt + assert "Hello world" in srt + assert "00:00:05,000 --> 00:00:08,000" in srt + assert "Second line" in srt + + def test_hour_padding_for_mm_ss_timestamps(self): + vtt = """WEBVTT + +01:30.000 --> 02:00.000 +Short timestamp""" + srt = vtt_to_srt(vtt) + assert "00:01:30,000 --> 00:02:00,000" in srt + + def test_strips_vtt_tags(self): + vtt = """WEBVTT + +00:00:01.000 --> 00:00:04.000 +Tagged text""" + srt = vtt_to_srt(vtt) + assert "Tagged text" in srt + assert "" not in srt + assert "" not in srt + + def test_renumbers_blocks(self): + vtt = """WEBVTT + +00:00:01.000 --> 00:00:02.000 +First + +00:00:03.000 --> 00:00:04.000 +Second""" + srt = vtt_to_srt(vtt) + lines = srt.strip().split("\n") + assert lines[0] == "1" + # Find second block number + block_nums = [line for line in lines if line.strip().isdigit()] + assert block_nums == ["1", "2"] From 09788a4e380000e923f2d3e889083ed2c2823114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Tue, 21 Apr 2026 11:15:05 +0800 Subject: [PATCH 2/3] fix: add per-entry "Add to Glossary" button and localize glossary-full error - Add BookOpen icon button on each subtitle entry's original text (hover to reveal) - Clicking adds the original text as a glossary term and opens the panel - Use i18n key glossary.full for capacity error instead of raw backend message Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/GlossaryPanel.tsx | 3 ++- frontend/src/components/SubtitleEditor.tsx | 24 +++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/GlossaryPanel.tsx b/frontend/src/components/GlossaryPanel.tsx index bb5e1ea..243e5b3 100644 --- a/frontend/src/components/GlossaryPanel.tsx +++ b/frontend/src/components/GlossaryPanel.tsx @@ -44,7 +44,8 @@ export function GlossaryPanel() { setNewTarget(''); setIsAdding(false); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to add term'); + const message = err instanceof Error ? err.message : ''; + setError(message.includes('full') ? t('glossary.full') : message || 'Failed to add term'); } }; diff --git a/frontend/src/components/SubtitleEditor.tsx b/frontend/src/components/SubtitleEditor.tsx index 5a6dd37..3deb6f5 100644 --- a/frontend/src/components/SubtitleEditor.tsx +++ b/frontend/src/components/SubtitleEditor.tsx @@ -583,9 +583,27 @@ export function SubtitleEditor({ jobId, onBurn, isBurning }: SubtitleEditorProps {/* Original text (read-only) */} {entry.original && ( -

- {entry.original} -

+
+

+ {entry.original} +

+ +
)}
))} From f71494702594a1c242fc417d2d6b27e961f2beac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maple=EF=BC=81?= Date: Tue, 21 Apr 2026 11:24:39 +0800 Subject: [PATCH 3/3] feat: show slow download hint for non-YouTube platforms Display a hint during download phase when the source URL is from X/Twitter, TikTok, or other non-YouTube platforms to set user expectations about download speed. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 5 +++++ frontend/src/hooks/useJob.ts | 9 ++++++--- frontend/src/i18n/en.json | 3 ++- frontend/src/i18n/zh-TW.json | 3 ++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbdc37d..81cc405 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,6 +66,11 @@ function App() { currentStep={state.currentStep} subtitleSource={state.subtitleSource ?? undefined} /> + {state.sourceUrl && + !state.sourceUrl.includes('youtube.com') && + !state.sourceUrl.includes('youtu.be') && ( +

{t('progress.nonYoutubeHint')}

+ )}
)} diff --git a/frontend/src/hooks/useJob.ts b/frontend/src/hooks/useJob.ts index 5dc0ce8..3853d54 100644 --- a/frontend/src/hooks/useJob.ts +++ b/frontend/src/hooks/useJob.ts @@ -16,6 +16,7 @@ interface JobState { | 'burned' | 'failed'; jobId: string | null; + sourceUrl: string | null; status: JobStatus | null; progress: number; currentStep: string | null; @@ -25,7 +26,7 @@ interface JobState { // Action types type JobAction = - | { type: 'SUBMIT' } + | { type: 'SUBMIT'; sourceUrl: string | null } | { type: 'JOB_CREATED'; jobId: string } | { type: 'PROGRESS'; data: SSEProgressData } | { type: 'DOWNLOAD_COMPLETE' } @@ -42,6 +43,7 @@ type JobAction = const initialState: JobState = { phase: 'idle', jobId: null, + sourceUrl: null, status: null, progress: 0, currentStep: null, @@ -52,7 +54,7 @@ const initialState: JobState = { function jobReducer(state: JobState, action: JobAction): JobState { switch (action.type) { case 'SUBMIT': - return { ...initialState, phase: 'submitting' }; + return { ...initialState, phase: 'submitting', sourceUrl: action.sourceUrl }; case 'JOB_CREATED': return { ...state, phase: 'processing', jobId: action.jobId }; case 'PROGRESS': @@ -127,7 +129,8 @@ export function useJob() { const submitJob = useCallback( async (request: JobCreateRequest | JobUploadRequest) => { cleanup(); - dispatch({ type: 'SUBMIT' }); + const sourceUrl = 'source_url' in request ? request.source_url : null; + dispatch({ type: 'SUBMIT', sourceUrl }); try { const response = diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index cf00c79..3e9584b 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -54,7 +54,8 @@ "failed": "Processing failed", "subtitleSource": "Source", "subtitleSourceYoutube": "YouTube (manual)", - "subtitleSourceWhisper": "Whisper" + "subtitleSourceWhisper": "Whisper", + "nonYoutubeHint": "Non-YouTube platforms may take longer to download" }, "download": { "title": "Download Results", diff --git a/frontend/src/i18n/zh-TW.json b/frontend/src/i18n/zh-TW.json index c31281f..4e7d777 100644 --- a/frontend/src/i18n/zh-TW.json +++ b/frontend/src/i18n/zh-TW.json @@ -54,7 +54,8 @@ "failed": "處理失敗", "subtitleSource": "字幕來源", "subtitleSourceYoutube": "YouTube(手動上傳)", - "subtitleSourceWhisper": "Whisper 語音辨識" + "subtitleSourceWhisper": "Whisper 語音辨識", + "nonYoutubeHint": "非 YouTube 平台的影片下載可能較慢" }, "download": { "title": "下載結果",